Support command options

Fixes #2655

Signed-off-by: mueller-ma <mueller-ma@users.noreply.github.com>

Support of command options

Support of command options
Items with command options are currently rendered as a simple switch (commands are so ignored).

These items, when associated to a Switch widget, should be rendered similar as items with state options, that is with one button for each command/state.

PS: I was planning to replace few switch channels in bindings by string channels with command options, but until it is supported by the Android app, it is not worth it.

Is there a difference between "command options" and "item options" (as in #912)?
No real difference in UI perspective.
But for command options, you will not receive state updates because this is used with channels that have no state.
Can you post a rest api response of an item with command options?

./gradlew :mobile:testFossBetaDebugUnitTest --tests "org.openhab.habdroid.model.ItemTest"

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 c0cc987668..db58bb994d 100644
--- a/mobile/src/main/java/org/openhab/habdroid/model/Item.kt
+++ b/mobile/src/main/java/org/openhab/habdroid/model/Item.kt
@@ -290,11 +290,20 @@ fun JSONObject.toItem(): Item {

     val stateDescription = optJSONObject("stateDescription")
     val readOnly = stateDescription != null && stateDescription.optBoolean("readOnly", false)
+    val commandDescription = optJSONObject("commandDescription")

-    val options = if (stateDescription?.has("options") == true) {
-        stateDescription.getJSONArray("options").map { obj -> obj.toLabeledValue("value", "label") }
-    } else {
-        null
+    val options = when {
+        commandDescription?.has("commandOptions") == true -> {
+            commandDescription.getJSONArray("commandOptions")
+                .map { obj -> obj.toLabeledValue("command", "label") }
+        }
+        stateDescription?.has("options") == true -> {
+            stateDescription.getJSONArray("options")
+                .map { obj -> obj.toLabeledValue("value", "label") }
+        }
+        else -> {
+            null
+        }
     }

     val members = if (has("members")) {

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 61df39b2ec..fe73dd2c89 100644
--- a/mobile/src/test/java/org/openhab/habdroid/model/ItemTest.kt
+++ b/mobile/src/test/java/org/openhab/habdroid/model/ItemTest.kt
@@ -104,6 +104,13 @@ class ItemTest {
         assertEquals("Location 2", sut.members[1].label)
     }

+    @Test
+    fun getCommandOptions() {
+        val sut = itemWithCommandOptions.toItem()
+        assertEquals(LabeledValue("1", "One"), sut.options!!.component1())
+        assertEquals(LabeledValue("2", "Two"), sut.options!!.component2())
+    }
+
     @Test
     fun getTags() {
         val sut = itemAsJsonObject.toItem()
@@ -192,5 +199,25 @@ class ItemTest {
                 'name': 'LocationGroup' }
             """.trimIndent()
         )
+        private val itemWithCommandOptions = JSONObject(
+            """
+              { 'state': 'NULL',
+                'type': 'Number',
+                'name': 'Foo',
+                'commandDescription': {
+                  'commandOptions': [
+                    {
+                      'command': '1',
+                      'label': 'One'
+                    },
+                    {
+                      'command': '2',
+                      'label': 'Two'
+                    },
+                  ]
+                }
+              }
+            """.trimIndent()
+        )
     }
 }


Make subtitle in device controls configurable

Fixes #2904

Use the semantic model for getting the location: https://github.com/openhab/openhab-core/issues/2977#issuecomment-1134772074

Android Device Controls: subtitle

Android Device Controls: subtitle
### Is your feature request related to a problem? Please describe.
Android Device Controls, subtitles missing. Opportunity to display useful Information like "item.name" missed.

### Describe the solution you'd like
Use` .setSubtitle() `to add additional Information like location or name of parent item.

I added:
`.setSubtitle(item.name)`
under line 319 and under line 326 in the file `ItemsControlsProviderService.kt` (path: \openhab-android-main\mobile\src\main\java\org\openhab\habdroid\background)

The results can be seen in the screenshots below.

### Describe alternatives you've considered
Showing the item location, instead of item name, would also be a good alternative.

### Additional context
**Current state (used paint to remove subtitles, as I use the app with my the implemented code now):**

**With implemented Code:**

There aren't many properties that are connected to Items: https://github.com/openhab/openhab-android/blob/main/mobile/src/main/java/org/openhab/habdroid/model/Item.kt#L30-L43

IMO the item name is too technical to be displayed here.

Maybe display the item state, e.g. for temperature?
Temperature and other item states are usually displayed on the right side of the icon :)

https://developer.android.com/guide/topics/ui/device-control
> IMO the item name is too technical to be displayed here.

Not sure. Think e.g. having 5 switches of some outlet relay plugs, which each have the default name of 'State' (or likewise 'Level' for roller shutters)... in that case, seeing the item name would be really helpful.
After all, we show the item name in the item picker as well...
>  After all, we show the item name in the item picker as well...

True, but the item picker isn't made for being used every day.

But I also see that it helps, so no veto from my side. @KingTomaHawk Do you want to open a PR?
@mueller-ma never done a PR till today, so please check if everything is done right :)

**achievement unlocked: create first PR**
TBH I'm not really happy with that change. The UI looked much cleaner before and for some users, e.g. for me, the item name doesn't add any information as the item label is already unique.

In the [documentation ](https://developer.android.com/guide/topics/ui/device-control#user-interface)the subtitle is the location of the item. We could do that, but I'm not sure how to get the location. For things I can set a location, but not for items....

The location could be set as [zone](https://developer.android.com/reference/android/service/controls/Control.StatefulBuilder#setZone(java.lang.CharSequence)). This makes it easier to find item when searching in the long list of items.

Maybe add a preference subscreen with a setting for the subtitle: Item name, location or nothing?
In this screen there could be an setting for https://developer.android.com/reference/android/service/controls/Control.StatefulBuilder#setAuthRequired(boolean)
Hmm.. I understand that. You said that it looked cleaner before, but I don't understand what exactly the problem is. Is the problem that some text doesn't fit (which I fully understand, that bothers me too) or that there is text at all?

But It seems like Android 13 Beta 2 broke something with Device Controls so I can't experiment with it for a while.
The "problem" for me is that the item name as subtitle adds redundant and technical information.

diff --git a/docs/USAGE.md b/docs/USAGE.md
index 65abf3cba2..a9d745735e 100644
--- a/docs/USAGE.md
+++ b/docs/USAGE.md
@@ -55,6 +55,7 @@ There are a number of strategies available to provide [secure remote access]({{b
 * [Send device information to openHAB](#send-device-information-to-openhab), like next alarm clock time or call state
 * [Supports wall mounted tablets](#permanent-deployment)
 * [Tasker](https://play.google.com/store/apps/details?id=net.dinglisch.android.taskerm) action plugin included
+* [Android Quick Access Device Controls](#quick-access-device-controls)

 ### Permanent Deployment

@@ -313,6 +314,11 @@ In case of an error the plugin returns an error code.
 | 11         | The app couldn't establish a connection                                                                                |
 | 1000+      | A connection was established, but an error occurred. The error code is 1000 + the HTTP code, e.g. 1401 means [Unauthenticated](https://en.wikipedia.org/wiki/List_of_HTTP_status_codes#4xx_client_errors). |

+### Quick Access Device Controls
+
+openHAB supports the device controls introduced in Android 11: https://www.android.com/intl/en_US/android-11/#a11-device-controls-article
+When using a [semantic model](https://www.openhab.org/docs/tutorial/model.html#semantic-model) the location and/or equipment name can be shown in the tiles.
+
 ## Multi server support

 When adding multiple servers to the app, there's always a primary and an active one.
diff --git a/mobile/src/main/java/org/openhab/habdroid/background/ItemsControlsProviderService.kt b/mobile/src/main/java/org/openhab/habdroid/background/ItemsControlsProviderService.kt
index 7d0ef1215e..e3da66a389 100644
--- a/mobile/src/main/java/org/openhab/habdroid/background/ItemsControlsProviderService.kt
+++ b/mobile/src/main/java/org/openhab/habdroid/background/ItemsControlsProviderService.kt
@@ -50,8 +50,10 @@ import org.openhab.habdroid.model.ParsedState
 import org.openhab.habdroid.model.ServerConfiguration
 import org.openhab.habdroid.model.toParsedState
 import org.openhab.habdroid.ui.MainActivity
+import org.openhab.habdroid.util.DeviceControlSubtitleMode
 import org.openhab.habdroid.util.HttpClient
 import org.openhab.habdroid.util.ItemClient
+import org.openhab.habdroid.util.getDeviceControlSubtitle
 import org.openhab.habdroid.util.getPrefs
 import org.openhab.habdroid.util.getPrimaryServerId
 import org.openhab.habdroid.util.getSecretPrefs
@@ -95,6 +97,7 @@ class ItemsControlsProviderService : ControlsProviderService() {
         val primaryServerName = ServerConfiguration.load(getPrefs(), getSecretPrefs(), getPrefs().getPrimaryServerId())
             ?.name
             .orDefaultIfEmpty(getString(R.string.app_name))
+        val subtitleMode = getPrefs().getDeviceControlSubtitle(applicationContext)
         val job = GlobalScope.launch {
             ConnectionFactory.waitForInitialization()
             val connection = ConnectionFactory.primaryUsableConnection?.connection
@@ -103,21 +106,16 @@ class ItemsControlsProviderService : ControlsProviderService() {
                 controls.done = true
                 return@launch
             }
-            val items = try {
-                ItemClient.loadItems(connection)
+            val allItems = try {
+                ItemClient.loadItems(connection) ?: return@launch
             } catch (e: HttpClient.HttpException) {
                 Log.e(TAG, "Could not load items", e)
                 controls.done = true
                 return@launch
-            }
-            if (items == null) {
-                Log.e(TAG, "Could not load items")
-                controls.done = true
-                return@launch
-            }
-            items.forEach { item ->
-                maybeCreateControl(item, primaryServerName, false)?.let { control -> controls.add(control) }
-            }
+            }.associateBy { item -> item.name }
+
+            allItems.mapNotNull { maybeCreateControl(it.value, allItems, primaryServerName, subtitleMode, false) }
+                .forEach { control -> controls.add(control) }
             controls.done = true
         }
         controls.onCancel = {
@@ -129,10 +127,10 @@ class ItemsControlsProviderService : ControlsProviderService() {
     override fun createPublisherFor(itemNames: MutableList<String>): Flow.Publisher<Control> {
         val publisher = SimplePublisher<Control>()
         var eventStream: EventSource? = null
-        val items = mutableMapOf<String, Item>()
         val primaryServerName = ServerConfiguration.load(getPrefs(), getSecretPrefs(), getPrefs().getPrimaryServerId())
             ?.name
             .orDefaultIfEmpty(getString(R.string.app_name))
+        val subtitleMode = getPrefs().getDeviceControlSubtitle(applicationContext)
         val job = launchWithConnection { connection ->
             if (connection == null) {
                 Log.e(TAG, "Got no connection for loading items")
@@ -140,26 +138,24 @@ class ItemsControlsProviderService : ControlsProviderService() {
                 return@launchWithConnection
             }

-            itemNames.forEach { itemName ->
-                try {
-                    ItemClient.loadItem(connection, itemName)?.let { item ->
-                        maybeCreateControl(item, primaryServerName, true)?.let { control ->
-                            items[item.name] = item
-                            publisher.add(control)
-                        }
-                    }
-                } catch (e: HttpClient.HttpException) {
-                    Log.e(TAG, "Could not load item $itemName", e)
-                }
-            }
+            val allItems = try {
+                ItemClient.loadItems(connection) ?: return@launchWithConnection
+            } catch (e: HttpClient.HttpException) {
+                Log.e(TAG, "Could not load items", e)
+                return@launchWithConnection
+            }.associateBy { item -> item.name }
+
+            allItems.filterKeys { itemName -> itemName in itemNames }
+                .mapNotNull { maybeCreateControl(it.value, allItems, primaryServerName, subtitleMode, true) }
+                .forEach { control -> publisher.add(control) }

             eventStream = connection.httpClient.makeSse(
                 // Support for both the "openhab" and the older "smarthome" root topic by using a wildcard
                 connection.httpClient.buildUrl("rest/events?topics=*/items/*/statechanged"),
                 StateChangeListener { itemName, state ->
-                    val item = items[itemName] ?: return@StateChangeListener
+                    val item = allItems[itemName] ?: return@StateChangeListener
                     val newItem = item.copy(state = state)
-                    maybeCreateControl(newItem, primaryServerName, true)?.let { control ->
+                    maybeCreateControl(newItem, allItems, primaryServerName, subtitleMode, true)?.let { control ->
                         publisher.add(control)
                     }
                 }
@@ -201,6 +197,27 @@ class ItemsControlsProviderService : ControlsProviderService() {
         }
     }

+    private fun getItemTagLabel(item: Item, allItems: Map<String, Item>, type: Item.Tag): String? {
+        val groups = item.groupNames.mapNotNull { groupName -> allItems[groupName] }
+        // First check if any of the groups is the requested type
+        groups.forEach { group ->
+            if (group.tags.any { tag -> tag == type }) {
+                return group.label
+            }
+            val tagByType = group.tags.firstOrNull { tag -> tag.parent == type }
+            if (tagByType != null) {
+                return if (group.label.isNullOrBlank() && tagByType.labelResId != null) {
+                    getString(tagByType.labelResId)
+                } else {
+                    group.label
+                }
+            }
+        }
+
+        // If none of the groups is location or equipment, recursively check parent groups
+        return groups.firstNotNullOfOrNull { group -> getItemTagLabel(group, allItems, type) }
+    }
+
     private fun getDeviceType(item: Item) =
         when (item.category?.lowercase()) {
             "screen", "soundvolume", "receiver" -> DeviceTypes.TYPE_TV
@@ -303,7 +320,13 @@ class ItemsControlsProviderService : ControlsProviderService() {
         )
     }

-    private fun maybeCreateControl(item: Item, serverName: String, stateful: Boolean): Control? {
+    private fun maybeCreateControl(
+        item: Item,
+        allItems: Map<String, Item>,
+        serverName: String,
+        subtitleMode: DeviceControlSubtitleMode,
+        stateful: Boolean
+    ): Control? {
         if (item.label.isNullOrEmpty() || item.readOnly) return null
         val controlTemplate = when (item.type) {
             Item.Type.Switch -> ToggleTemplate(
@@ -323,22 +346,36 @@ class ItemsControlsProviderService : ControlsProviderService() {
             else -> return null
         }

+        val location = getItemTagLabel(item, allItems, Item.Tag.Location).orEmpty()
+        val equipment = getItemTagLabel(item, allItems, Item.Tag.Equipment).orEmpty()
+
+        val subtitle = when (subtitleMode) {
+            DeviceControlSubtitleMode.LOCATION -> location
+            DeviceControlSubtitleMode.EQUIPMENT -> equipment
+            DeviceControlSubtitleMode.LOCATION_AND_EQUIPMENT -> "$location $equipment"
+            DeviceControlSubtitleMode.ITEM_NAME -> item.name
+        }
+
+        val zone = if (subtitleMode == DeviceControlSubtitleMode.ITEM_NAME) {
+            item.name.first().toString()
+        } else {
+            location
+        }
+
+        val statefulControl = Control.StatefulBuilder(item.name, mainActivityPendingIntent)
+            .setTitle(item.label)
+            .setSubtitle(subtitle)
+            .setZone(zone)
+            .setStructure(serverName)
+            .setDeviceType(getDeviceType(item))
+            .setControlTemplate(controlTemplate)
+            .setStatus(Control.STATUS_OK)
+            .build()
+
         return if (stateful) {
-            Control.StatefulBuilder(item.name, mainActivityPendingIntent)
-                .setTitle(item.label)
-                .setSubtitle(item.name)
-                .setStructure(serverName)
-                .setDeviceType(getDeviceType(item))
-                .setControlTemplate(controlTemplate)
-                .setStatus(Control.STATUS_OK)
-                .build()
+            statefulControl
         } else {
-            Control.StatelessBuilder(item.name, mainActivityPendingIntent)
-                .setTitle(item.label)
-                .setSubtitle(item.name)
-                .setStructure(serverName)
-                .setDeviceType(getDeviceType(item))
-                .build()
+            Control.StatelessBuilder(statefulControl).build()
         }
     }

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 6439cc8b5f..c0cc987668 100644
--- a/mobile/src/main/java/org/openhab/habdroid/model/Item.kt
+++ b/mobile/src/main/java/org/openhab/habdroid/model/Item.kt
@@ -18,6 +18,7 @@ import java.util.Locale
 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
@@ -38,6 +39,7 @@ data class Item internal constructor(
     val options: List<LabeledValue>?,
     val state: ParsedState?,
     val tags: List<Tag>,
+    val groupNames: List<String>,
     val minimum: Double?,
     val maximum: Double?,
     val step: Double?,
@@ -60,149 +62,155 @@ data class Item internal constructor(
     }

     @Suppress("unused")
-    enum class Tag {
+    enum class Tag(val parent: Tag?, val labelResId: Int?) {
         // Legacy tags from openHAB 2
-        ContactSensor,
-        HeatingCoolingMode,
-        TargetTemperature,
+        ContactSensor(null, null),
+        HeatingCoolingMode(null, null),
+        TargetTemperature(null, null),
+
+        // Fallback
+        Unknown(null, null),

         // Semantic types
-        Equipment,
-        Location,
-        Point,
-        Property,
+        Equipment(null, null),
+        Location(null, null),
+        Point(null, null),
+        Property(null, null),

-        // Semantic tags
-        Alarm,
-        AlarmSystem,
-        Apartment,
-        Attic,
-        BackDoor,
-        Basement,
-        Bathroom,
-        Battery,
-        Bedroom,
-        Blinds,
-        Boiler,
-        BoilerRoom,
-        Building,
-        CO,
-        CO2,
-        Camera,
-        Car,
-        Carport,
-        CeilingFan,
-        Cellar,
-        CellarDoor,
-        CleaningRobot,
-        ColorTemperature,
-        Control,
-        Corridor,
-        Current,
-        DiningRoom,
-        Dishwasher,
-        Door,
-        Doorbell,
-        Driveway,
-        Dryer,
-        Duration,
-        Energy,
-        Entry,
-        FamilyRoom,
-        Fan,
-        FirstFloor,
-        Floor,
-        Freezer,
-        Frequency,
-        FrontDoor,
-        Garage,
-        GarageDoor,
-        Garden,
-        Gas,
-        Gate,
-        GroundFloor,
-        GuestRoom,
-        HVAC,
-        House,
-        Humidity,
-        Indoor,
-        InnerDoor,
-        Inverter,
-        Kitchen,
-        KitchenHood,
-        LaundryRoom,
-        LawnMower,
-        Level,
-        Light,
-        LightStripe,
-        Lightbulb,
-        LivingRoom,
-        Lock,
-        LowBattery,
-        Measurement,
-        MotionDetector,
-        NetworkAppliance,
-        Noise,
-        Office,
-        Oil,
-        OpenLevel,
-        OpenState,
-        Opening,
-        Outdoor,
-        Oven,
-        Patio,
-        Porch,
-        Power,
-        PowerOutlet,
-        Presence,
-        Pressure,
-        Projector,
-        Pump,
-        RadiatorControl,
-        Rain,
-        Receiver,
-        Refrigerator,
-        RemoteControl,
-        Room,
-        Screen,
-        SecondFloor,
-        Sensor,
-        Setpoint,
-        Shed,
-        SideDoor,
-        Siren,
-        Smartphone,
-        Smoke,
-        SmokeDetector,
-        SoundVolume,
-        Speaker,
-        Status,
-        SummerHouse,
-        Switch,
-        Tampered,
-        Television,
-        Temperature,
-        Terrace,
-        ThirdFloor,
-        Tilt,
-        Timestamp,
-        Ultraviolet,
-        Valve,
-        Veranda,
-        Vibration,
-        VoiceAssistant,
-        Voltage,
-        WallSwitch,
-        WashingMachine,
-        Water,
-        WeatherService,
-        WebService,
-        WhiteGood,
-        Wind,
-        Window,
+        // Semantic tags: point types
+        Alarm(Point, null),
+        Control(Point, null),
+        Measurement(Point, null),
+        LowBattery(Point, null),
+        OpenLevel(Point, null),
+        OpenState(Point, null),
+        Setpoint(Point, null),
+        Status(Point, null),
+        Switch(Point, null),
+        Tampered(Point, null),
+        Tilt(Point, null),

-        // Fallback
-        Unknown
+        // Semantic tags: measurement types
+        CO(Measurement, null),
+        CO2(Measurement, null),
+        ColorTemperature(Measurement, null),
+        Current(Measurement, null),
+        Duration(Measurement, null),
+        Energy(Measurement, null),
+        Frequency(Measurement, null),
+        Gas(Measurement, null),
+        Humidity(Measurement, null),
+        Level(Measurement, null),
+        Light(Measurement, null),
+        Noise(Measurement, null),
+        Oil(Measurement, null),
+        Opening(Measurement, null),
+        Power(Measurement, null),
+        Presence(Measurement, null),
+        Pressure(Measurement, null),
+        Rain(Measurement, null),
+        Smoke(Measurement, null),
+        SoundVolume(Measurement, null),
+        Temperature(Measurement, null),
+        Timestamp(Measurement, null),
+        Ultraviolet(Measurement, null),
+        Vibration(Measurement, null),
+        Voltage(Measurement, null),
+        Water(Measurement, null),
+        Wind(Measurement, null),
+
+        // Semantic tags: location
+        Apartment(Location, R.string.item_tag_location_apartment),
+        Attic(Location, R.string.item_tag_location_attic),
+        Basement(Location, R.string.item_tag_location_basement),
+        Bathroom(Location, R.string.item_tag_location_bathroom),
+        Bedroom(Location, R.string.item_tag_location_bedroom),
+        BoilerRoom(Location, R.string.item_tag_location_boilerroom),
+        Building(Location, R.string.item_tag_location_building),
+        Carport(Location, R.string.item_tag_location_carport),
+        Cellar(Location, R.string.item_tag_location_cellar),
+        Corridor(Location, R.string.item_tag_location_corridor),
+        DiningRoom(Location, R.string.item_tag_location_diningroom),
+        Driveway(Location, R.string.item_tag_location_driveway),
+        Entry(Location, R.string.item_tag_location_entry),
+        FamilyRoom(Location, R.string.item_tag_location_familyroom),
+        FirstFloor(Location, R.string.item_tag_location_firstfloor),
+        Floor(Location, R.string.item_tag_location_floor),
+        Garage(Location, R.string.item_tag_location_garage),
+        Garden(Location, R.string.item_tag_location_garden),
+        GroundFloor(Location, R.string.item_tag_location_groundfloor),
+        GuestRoom(Location, R.string.item_tag_location_guestroom),
+        House(Location, R.string.item_tag_location_house),
+        Indoor(Location, R.string.item_tag_location_indoor),
+        Kitchen(Location, R.string.item_tag_location_kitchen),
+        LaundryRoom(Location, R.string.item_tag_location_laundryroom),
+        LivingRoom(Location, R.string.item_tag_location_livingroom),
+        Office(Location, R.string.item_tag_location_office),
+        Outdoor(Location, R.string.item_tag_location_outdoor),
+        Patio(Location, R.string.item_tag_location_patio),
+        Porch(Location, R.string.item_tag_location_porch),
+        Room(Location, R.string.item_tag_location_room),
+        SecondFloor(Location, R.string.item_tag_location_secondfloor),
+        Shed(Location, R.string.item_tag_location_shed),
+        SummerHouse(Location, R.string.item_tag_location_summerhouse),
+        Terrace(Location, R.string.item_tag_location_terrace),
+        ThirdFloor(Location, R.string.item_tag_location_thirdfloor),
+        Veranda(Location, R.string.item_tag_location_veranda),
+
+        // Semantic tags: equipment
+        AlarmSystem(Equipment, R.string.item_tag_equipment_alarmsystem),
+        BackDoor(Equipment, R.string.item_tag_equipment_backdoor),
+        Battery(Equipment, R.string.item_tag_equipment_battery),
+        Blinds(Equipment, R.string.item_tag_equipment_blinds),
+        Boiler(Equipment, R.string.item_tag_equipment_boiler),
+        Camera(Equipment, R.string.item_tag_equipment_camera),
+        Car(Equipment, R.string.item_tag_equipment_car),
+        CeilingFan(Equipment, R.string.item_tag_equipment_ceilingfan),
+        CellarDoor(Equipment, R.string.item_tag_equipment_cellardoor),
+        CleaningRobot(Equipment, R.string.item_tag_equipment_cleaningrobot),
+        Dishwasher(Equipment, R.string.item_tag_equipment_dishwasher),
+        Door(Equipment, R.string.item_tag_equipment_door),
+        Doorbell(Equipment, R.string.item_tag_equipment_doorbell),
+        Dryer(Equipment, R.string.item_tag_equipment_dryer),
+        Fan(Equipment, R.string.item_tag_equipment_fan),
+        Freezer(Equipment, R.string.item_tag_equipment_freezer),
+        FrontDoor(Equipment, R.string.item_tag_equipment_frontdoor),
+        GarageDoor(Equipment, R.string.item_tag_equipment_garagedoor),
+        Gate(Equipment, R.string.item_tag_equipment_gate),
+        HVAC(Equipment, R.string.item_tag_equipment_hvac),
+        InnerDoor(Equipment, R.string.item_tag_equipment_innerdoor),
+        Inverter(Equipment, R.string.item_tag_equipment_inverter),
+        KitchenHood(Equipment, R.string.item_tag_equipment_kitchenhood),
+        LawnMower(Equipment, R.string.item_tag_equipment_lawnmower),
+        Lightbulb(Equipment, R.string.item_tag_equipment_lightbulb),
+        LightStripe(Equipment, R.string.item_tag_equipment_lightstripe),
+        Lock(Equipment, R.string.item_tag_equipment_lock),
+        MotionDetector(Equipment, R.string.item_tag_equipment_motiondetector),
+        NetworkAppliance(Equipment, R.string.item_tag_equipment_networkappliance),
+        Oven(Equipment, R.string.item_tag_equipment_oven),
+        PowerOutlet(Equipment, R.string.item_tag_equipment_poweroutlet),
+        Projector(Equipment, R.string.item_tag_equipment_projector),
+        Pump(Equipment, R.string.item_tag_equipment_pump),
+        RadiatorControl(Equipment, R.string.item_tag_equipment_radiatorcontrol),
+        Receiver(Equipment, R.string.item_tag_equipment_receiver),
+        Refrigerator(Equipment, R.string.item_tag_equipment_refrigerator),
+        RemoteControl(Equipment, R.string.item_tag_equipment_remotecontrol),
+        Screen(Equipment, R.string.item_tag_equipment_screen),
+        Sensor(Equipment, R.string.item_tag_equipment_sensor),
+        SideDoor(Equipment, R.string.item_tag_equipment_sidedoor),
+        Siren(Equipment, R.string.item_tag_equipment_siren),
+        Smartphone(Equipment, R.string.item_tag_equipment_smartphone),
+        SmokeDetector(Equipment, R.string.item_tag_equipment_smokedetector),
+        Speaker(Equipment, R.string.item_tag_equipment_speaker),
+        Television(Equipment, R.string.item_tag_equipment_television),
+        Valve(Equipment, R.string.item_tag_equipment_valve),
+        VoiceAssistant(Equipment, R.string.item_tag_equipment_voiceassistant),
+        WallSwitch(Equipment, R.string.item_tag_equipment_wallswitch),
+        WashingMachine(Equipment, R.string.item_tag_equipment_washingmachine),
+        WeatherService(Equipment, R.string.item_tag_equipment_weatherservice),
+        WebService(Equipment, R.string.item_tag_equipment_webservice),
+        WhiteGood(Equipment, R.string.item_tag_equipment_whitegood),
+        Window(Equipment, R.string.item_tag_equipment_window)
     }

     fun isOfTypeOrGroupType(type: Type): Boolean {
@@ -254,20 +262,21 @@ fun Node.toItem(): Item? {
     }

     return Item(
-        finalName,
-        finalName.trim(),
-        null,
-        type,
-        groupType,
-        link,
-        false,
-        emptyList(),
-        null,
-        state.toParsedState(),
-        emptyList(),
-        null,
-        null,
-        null
+        name = finalName,
+        label = finalName.trim(),
+        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
     )
 }

@@ -310,6 +319,12 @@ fun JSONObject.toItem(): Item {
         emptyList()
     }

+    val groupNames = if (has("groupNames")) {
+        getJSONArray("groupNames").mapString { it }
+    } else {
+        emptyList()
+    }
+
     return Item(
         name,
         optStringOrNull("label")?.trim(),
@@ -322,6 +337,7 @@ fun JSONObject.toItem(): Item {
         options,
         state.toParsedState(numberPattern),
         tags,
+        groupNames,
         stateDescription?.optDoubleOrNull("minimum"),
         stateDescription?.optDoubleOrNull("maximum"),
         stateDescription?.optDoubleOrNull("step")
diff --git a/mobile/src/main/java/org/openhab/habdroid/util/PrefExtensions.kt b/mobile/src/main/java/org/openhab/habdroid/util/PrefExtensions.kt
index 69673d069e..6d25d6570f 100644
--- a/mobile/src/main/java/org/openhab/habdroid/util/PrefExtensions.kt
+++ b/mobile/src/main/java/org/openhab/habdroid/util/PrefExtensions.kt
@@ -188,6 +188,23 @@ fun SharedPreferences.isItemUpdatePrefEnabled(key: String) = getString(key, null

 fun SharedPreferences.isEventListenerEnabled() = getBoolean(PrefKeys.SEND_DEVICE_INFO_FOREGROUND_SERVICE, false)

+enum class DeviceControlSubtitleMode {
+    LOCATION,
+    EQUIPMENT,
+    LOCATION_AND_EQUIPMENT,
+    ITEM_NAME
+}
+
+fun SharedPreferences.getDeviceControlSubtitle(context: Context): DeviceControlSubtitleMode {
+    return when (getStringOrNull(PrefKeys.DEVICE_CONTROL_SUBTITLE)) {
+        context.getString(R.string.device_control_subtitle_equipment_value) -> DeviceControlSubtitleMode.EQUIPMENT
+        context.getString(R.string.device_control_subtitle_location_equipment_value) ->
+            DeviceControlSubtitleMode.LOCATION_AND_EQUIPMENT
+        context.getString(R.string.device_control_subtitle_item_name_value) -> DeviceControlSubtitleMode.ITEM_NAME
+        else -> DeviceControlSubtitleMode.LOCATION
+    }
+}
+
 /**
  * Returns vibration pattern for notifications that can be passed to
  * [}][androidx.core.app.NotificationCompat.Builder.setVibrate]
diff --git a/mobile/src/main/java/org/openhab/habdroid/util/PrefKeys.kt b/mobile/src/main/java/org/openhab/habdroid/util/PrefKeys.kt
index 21a38d47b8..5a94a63caa 100644
--- a/mobile/src/main/java/org/openhab/habdroid/util/PrefKeys.kt
+++ b/mobile/src/main/java/org/openhab/habdroid/util/PrefKeys.kt
@@ -72,6 +72,7 @@ object PrefKeys {
     const val DEV_ID_PREFIX_BG_TASKS = "device_identifier_prefix_background_tasks"
     const val SCREEN_LOCK = "screen_lock"
     const val TASKER_PLUGIN_ENABLED = "taskerPlugin"
+    const val DEVICE_CONTROL_SUBTITLE = "device_control_subtitle"
     const val DATA_SAVER = "data_saver"
     const val CRASH_REPORTING = "crash_reporting"
     const val DEBUG_MESSAGES = "default_openhab_debug_messages"
diff --git a/mobile/src/main/res/values/arrays.xml b/mobile/src/main/res/values/arrays.xml
index 43f57fae84..e135627ee1 100644
--- a/mobile/src/main/res/values/arrays.xml
+++ b/mobile/src/main/res/values/arrays.xml
@@ -130,6 +130,20 @@
         <item>@string/settings_screen_lock_on_value</item>
     </string-array>

+    <string-array name="device_control_subtitle">
+        <item>@string/device_control_subtitle_location</item>
+        <item>@string/device_control_subtitle_equipment</item>
+        <item>@string/device_control_subtitle_location_equipment</item>
+        <item>@string/device_control_subtitle_item_name</item>
+    </string-array>
+
+    <string-array name="device_control_subtitle_values">
+        <item>@string/device_control_subtitle_location_value</item>
+        <item>@string/device_control_subtitle_equipment_value</item>
+        <item>@string/device_control_subtitle_location_equipment_value</item>
+        <item>@string/device_control_subtitle_item_name_value</item>
+    </string-array>
+
     <array name="accentColors">
         <item>@color/openhab_orange</item>
         <item>@color/indigo_500</item>
diff --git a/mobile/src/main/res/values/strings.xml b/mobile/src/main/res/values/strings.xml
index b82b1b837f..3835deaa5c 100644
--- a/mobile/src/main/res/values/strings.xml
+++ b/mobile/src/main/res/values/strings.xml
@@ -130,6 +130,15 @@
     <string name="settings_multi_server_wifi_ssid_location_off">Unable to read Wi-Fi name: Location is off</string>
     <string name="setting_crash_reporting_title">Crash reporting</string>
     <string name="setting_crash_reporting_summary">Send crash reports to support the development of the app</string>
+    <string name="device_control_subtitle_location">Location</string>
+    <string name="device_control_subtitle_equipment">Equipment</string>
+    <string name="device_control_subtitle_location_equipment">Location and equipment</string>
+    <string name="device_control_subtitle_item_name">Item name</string>
+    <string name="device_control_subtitle_location_value" translatable="false">location</string>
+    <string name="device_control_subtitle_equipment_value" translatable="false">equipment</string>
+    <string name="device_control_subtitle_location_equipment_value" translatable="false">location_equipment</string>
+    <string name="device_control_subtitle_item_name_value" translatable="false">item_name</string>
+    <string name="device_control_subtitle">Device control subtitle</string>

     <!-- App messages strings -->
     <string name="title_voice_widget">Voice commands</string>
@@ -547,6 +556,7 @@
     <string name="widget_show_state">Append current state to name</string>
     <string name="widget_show_state_summary">The state is updated on the schedule that can be set in the settings under \"Send device information to server\"</string>
     <string name="widget_no_command">Don\'t send a command</string>
+    <string name="error_getting_state">Error loading state</string>
     <string name="item_update_success_message_generic">%1$s has been set to %2$s</string>
     <string name="item_update_success_message_on">%s has been turned on</string>
     <string name="item_update_success_message_off">%s has been turned off</string>
@@ -724,5 +734,95 @@
     <string name="tile_icon_lamp_floor_value" translatable="false">lamp_floor</string>
     <string name="tile_icon_lamp_bedside_value" translatable="false">lamp_bedside</string>
     <string name="tile_icon_lamp_outdoor_value" translatable="false">lamp_outdoor</string>
-    <string name="error_getting_state">Error loading state</string>
+
+    <!-- Item tag type labels -->
+    <string name="item_tag_location_apartment">Apartment</string>
+    <string name="item_tag_location_attic">Attic</string>
+    <string name="item_tag_location_basement">Basement</string>
+    <string name="item_tag_location_bathroom">Bathroom</string>
+    <string name="item_tag_location_bedroom">Bedroom</string>
+    <string name="item_tag_location_boilerroom">Boiler room</string>
+    <string name="item_tag_location_building">Building</string>
+    <string name="item_tag_location_carport">Carport</string>
+    <string name="item_tag_location_cellar">Cellar</string>
+    <string name="item_tag_location_corridor">Corridor</string>
+    <string name="item_tag_location_diningroom">Dining room</string>
+    <string name="item_tag_location_driveway">Driveway</string>
+    <string name="item_tag_location_entry">Entry</string>
+    <string name="item_tag_location_familyroom">Family room</string>
+    <string name="item_tag_location_firstfloor">First floor</string>
+    <string name="item_tag_location_floor">Floor</string>
+    <string name="item_tag_location_garage">Garage</string>
+    <string name="item_tag_location_garden">Garden</string>
+    <string name="item_tag_location_groundfloor">Ground floor</string>
+    <string name="item_tag_location_guestroom">Guest room</string>
+    <string name="item_tag_location_house">House</string>
+    <string name="item_tag_location_indoor">Indoor</string>
+    <string name="item_tag_location_kitchen">Kitchen</string>
+    <string name="item_tag_location_laundryroom">Laundry room</string>
+    <string name="item_tag_location_livingroom">Living room</string>
+    <string name="item_tag_location_office">Office</string>
+    <string name="item_tag_location_outdoor">Outdoor</string>
+    <string name="item_tag_location_patio">Patio</string>
+    <string name="item_tag_location_porch">Porch</string>
+    <string name="item_tag_location_room">Room</string>
+    <string name="item_tag_location_secondfloor">Second floor</string>
+    <string name="item_tag_location_shed">Shed</string>
+    <string name="item_tag_location_summerhouse">Summer house</string>
+    <string name="item_tag_location_terrace">Terrace</string>
+    <string name="item_tag_location_thirdfloor">Third floor</string>
+    <string name="item_tag_location_veranda">Veranda</string>
+    <string name="item_tag_equipment_alarmsystem">Alarm system</string>
+    <string name="item_tag_equipment_backdoor">Back door</string>
+    <string name="item_tag_equipment_battery">Battery</string>
+    <string name="item_tag_equipment_blinds">Blinds</string>
+    <string name="item_tag_equipment_boiler">Boiler</string>
+    <string name="item_tag_equipment_camera">Camera</string>
+    <string name="item_tag_equipment_car">Car</string>
+    <string name="item_tag_equipment_ceilingfan">CeilingFan</string>
+    <string name="item_tag_equipment_cellardoor">Cellar door</string>
+    <string name="item_tag_equipment_cleaningrobot">Cleaning robot</string>
+    <string name="item_tag_equipment_dishwasher">Dishwasher</string>
+    <string name="item_tag_equipment_door">Door</string>
+    <string name="item_tag_equipment_doorbell">Doorbell</string>
+    <string name="item_tag_equipment_dryer">Dryer</string>
+    <string name="item_tag_equipment_fan">Fan</string>
+    <string name="item_tag_equipment_freezer">Freezer</string>
+    <string name="item_tag_equipment_frontdoor">Front door</string>
+    <string name="item_tag_equipment_garagedoor">Garage door</string>
+    <string name="item_tag_equipment_gate">Gate</string>
+    <string name="item_tag_equipment_hvac">HVAC</string>
+    <string name="item_tag_equipment_innerdoor">Inner door</string>
+    <string name="item_tag_equipment_inverter">Inverter</string>
+    <string name="item_tag_equipment_kitchenhood">Kitchen hood</string>
+    <string name="item_tag_equipment_lawnmower">Lawn mower</string>
+    <string name="item_tag_equipment_lightbulb">Lightbulb</string>
+    <string name="item_tag_equipment_lightstripe">LightStripe</string>
+    <string name="item_tag_equipment_lock">Lock</string>
+    <string name="item_tag_equipment_motiondetector">Motion detector</string>
+    <string name="item_tag_equipment_networkappliance">Network appliance</string>
+    <string name="item_tag_equipment_oven">Oven</string>
+    <string name="item_tag_equipment_poweroutlet">Power outlet</string>
+    <string name="item_tag_equipment_projector">Projector</string>
+    <string name="item_tag_equipment_pump">Pump</string>
+    <string name="item_tag_equipment_radiatorcontrol">Radiator control</string>
+    <string name="item_tag_equipment_receiver">Receiver</string>
+    <string name="item_tag_equipment_refrigerator">Refrigerator</string>
+    <string name="item_tag_equipment_remotecontrol">Remote control</string>
+    <string name="item_tag_equipment_screen">Screen</string>
+    <string name="item_tag_equipment_sensor">Sensor</string>
+    <string name="item_tag_equipment_sidedoor">Side door</string>
+    <string name="item_tag_equipment_siren">Siren</string>
+    <string name="item_tag_equipment_smartphone">Smartphone</string>
+    <string name="item_tag_equipment_smokedetector">Smoke detector</string>
+    <string name="item_tag_equipment_speaker">Speaker</string>
+    <string name="item_tag_equipment_television">Television</string>
+    <string name="item_tag_equipment_valve">Valve</string>
+    <string name="item_tag_equipment_voiceassistant">Voice assistant</string>
+    <string name="item_tag_equipment_wallswitch">Wall switch</string>
+    <string name="item_tag_equipment_washingmachine">Washing machine</string>
+    <string name="item_tag_equipment_weatherservice">Weather service</string>
+    <string name="item_tag_equipment_webservice">Web service</string>
+    <string name="item_tag_equipment_whitegood">White good</string>
+    <string name="item_tag_equipment_window">Window</string>
 </resources>
diff --git a/mobile/src/main/res/xml/preferences.xml b/mobile/src/main/res/xml/preferences.xml
index c14d8c74fc..e051b82def 100644
--- a/mobile/src/main/res/xml/preferences.xml
+++ b/mobile/src/main/res/xml/preferences.xml
@@ -138,6 +138,13 @@
             android:entries="@array/screen_lock"
             android:entryValues="@array/screen_lock_values"
             android:icon="@drawable/ic_lock_outline_grey_24dp" />
+        <ListPreference
+            android:key="device_control_subtitle"
+            android:title="@string/device_control_subtitle"
+            android:defaultValue="@string/device_control_subtitle_location_value"
+            app:useSimpleSummaryProvider="true"
+            android:entries="@array/device_control_subtitle"
+            android:entryValues="@array/device_control_subtitle_values"/>
         <SwitchPreference
             android:defaultValue="false"
             android:key="taskerPlugin"

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 d4cff1b4e8..61df39b2ec 100644
--- a/mobile/src/test/java/org/openhab/habdroid/model/ItemTest.kt
+++ b/mobile/src/test/java/org/openhab/habdroid/model/ItemTest.kt
@@ -153,6 +153,16 @@ class ItemTest {
         assertNotEquals(sut1a.hashCode(), sut2a.hashCode())
     }

+    @Test
+    fun tagsHaveLabel() {
+        val mustHaveLabel = listOf(Item.Tag.Location, Item.Tag.Equipment)
+        Item.Tag.values()
+            .filter { tag -> tag.parent in mustHaveLabel }
+            .forEach { tag ->
+                assertNotNull(tag.labelResId)
+            }
+    }
+
     companion object {
         private val itemAsJsonObjectWithMembers = JSONObject(
             """


handle HTTP 413 response from OSM API

fixes #6151

Need to add tests (but not tonight). Is the code itself comprehensible, @Helium314 , @FloEdelmann , @matkoniecz ?

Upload error on too large bounding box

Upload error on too large bounding box
I am getting some crash reports.

```
de.westnordost.streetcomplete.data[https://de.westnordost.streetcomplete.data/].ApiClientException: Client request(POST https://api.openstreetmap.org/api/0.6/changeset/163137077/upload) invalid: 413 Payload Too Large. Text: "Changeset bounding box size limit exceeded."
at de.westnordost.streetcomplete.data[https://de.westnordost.streetcomplete.data/].osm.mapdata.MapDataApiClient.uploadChanges(SourceFile:42)
at de.westnordost.streetcomplete.data[https://de.westnordost.streetcomplete.data/].osm.mapdata.MapDataApiClient$uploadChanges$1.invokeSuspend(Unknown Source:16)
at kotlin.coroutines.jvm.internal.BaseContinuationImpl.resumeWith(SourceFile:33)
at kotlinx.coroutines.DispatchedTask.run[https://kotlinx.coroutines.dispatchedtask.run/](SourceFile:100)
at kotlinx.coroutines.internal.LimitedDispatcher$Worker.run[https://worker.run/](SourceFile:113)
at kotlinx.coroutines.scheduling.TaskImpl.run[https://kotlinx.coroutines.scheduling.taskimpl.run/](SourceFile:89)
at kotlinx.coroutines.scheduling.CoroutineScheduler.runSafely(SourceFile:586)
at kotlinx.coroutines.scheduling.CoroutineScheduler$Worker.executeTask(SourceFile:820)
at kotlinx.coroutines.scheduling.CoroutineScheduler$Worker.runWorker(SourceFile:717)
at kotlinx.coroutines.scheduling.CoroutineScheduler$Worker.run[https://worker.run/](SourceFile:704)
Caused by: io.ktor.client.plugins.ClientRequestException: Client request(POST https://api.openstreetmap.org/api/0.6/changeset/163137077/upload) invalid: 413 Payload Too Large. Text: "Changeset bounding box size limit exceeded."
at io.ktor.client.plugins.DefaultResponseValidationKt$addDefaultResponseValidation$1$1.invokeSuspend(SourceFile:52)
... 8 more
```

Some time ago, the OpenStreetMap API introduced some API limits.

One of these limits was a maximum bounding box size of changesets, relative to how new the user is.

I understand why it was done. A vandal moved nodes of (renamed to something vulgar) a couple of (hundreds of) ways over half the world to achieve an effect like criss-crossing barrier-tape on the rendered map with said vulgar writing on it. A side-effect was that the rendering pipeline of map renderers was completely overloaded, as suddenly millions and millions of tiles needed to be re-rendered.

The side-effect of this measure is, even though nodes of ways cannot be moved at all in StreetComplete, that this app is affected, because of how the limit was implemented. It is rather annoying that this limit wasn't implemented as a limit for the maximum distance a node (of a way) can be moved, but as a maximum bounding box size.

Well, anyway, we have to handle a 413 HTTP error code on upload now and as a response, create a new changeset, then retry the upload.

./gradlew :app:testDebugUnitTest --tests "de.westnordost.streetcomplete.data.osm.edits.upload.ElementEditUploaderTest"

diff --git a/app/src/main/java/de/westnordost/streetcomplete/data/osm/edits/upload/ElementEditUploader.kt b/app/src/main/java/de/westnordost/streetcomplete/data/osm/edits/upload/ElementEditUploader.kt
index 184e6118c18..5d0644a5386 100644
--- a/app/src/main/java/de/westnordost/streetcomplete/data/osm/edits/upload/ElementEditUploader.kt
+++ b/app/src/main/java/de/westnordost/streetcomplete/data/osm/edits/upload/ElementEditUploader.kt
@@ -1,10 +1,12 @@
 package de.westnordost.streetcomplete.data.osm.edits.upload

-import de.westnordost.streetcomplete.ApplicationConstants
+import de.westnordost.streetcomplete.ApplicationConstants.EDIT_ACTIONS_NOT_ALLOWED_TO_USE_LOCAL_CHANGES
+import de.westnordost.streetcomplete.ApplicationConstants.IGNORED_RELATION_TYPES
 import de.westnordost.streetcomplete.data.ConflictException
 import de.westnordost.streetcomplete.data.osm.edits.ElementEdit
 import de.westnordost.streetcomplete.data.osm.edits.ElementIdProvider
 import de.westnordost.streetcomplete.data.osm.edits.upload.changesets.OpenChangesetsManager
+import de.westnordost.streetcomplete.data.osm.mapdata.ChangesetTooLargeException
 import de.westnordost.streetcomplete.data.osm.mapdata.MapDataApiClient
 import de.westnordost.streetcomplete.data.osm.mapdata.MapDataChanges
 import de.westnordost.streetcomplete.data.osm.mapdata.MapDataController
@@ -22,33 +24,59 @@ class ElementEditUploader(
      *  @throws ConflictException if element has been changed server-side in an incompatible way
      */
     suspend fun upload(edit: ElementEdit, getIdProvider: () -> ElementIdProvider): MapDataUpdates {
-        val remoteChanges by lazy { edit.action.createUpdates(RemoteMapDataRepository(mapDataApi), getIdProvider()) }
-        val localChanges by lazy { edit.action.createUpdates(mapDataController, getIdProvider()) }
-
-        val mustUseRemoteData = edit.action::class in ApplicationConstants.EDIT_ACTIONS_NOT_ALLOWED_TO_USE_LOCAL_CHANGES
+        // certain edit types don't allow building changes on top of cached map data
+        val mustUseRemoteData = edit.action::class in EDIT_ACTIONS_NOT_ALLOWED_TO_USE_LOCAL_CHANGES

         return if (mustUseRemoteData) {
-            try {
-                uploadChanges(edit, remoteChanges, false)
-            } catch (e: ConflictException) {
-                // probably changeset closed
-                uploadChanges(edit, remoteChanges, true)
-            }
+            uploadUsingRemoteRepo(edit, getIdProvider)
         } else {
+            // we first try to apply the changes onto the element cached locally, then upload...
             try {
-                uploadChanges(edit, localChanges, false)
-            } catch (e: ConflictException) {
-                // either changeset was closed, or element modified, or local element was cleaned from db
+                val localChanges = edit.action.createUpdates(mapDataController, getIdProvider())
                 try {
-                    uploadChanges(edit, remoteChanges, false)
-                } catch (e: ConflictException) {
-                    // probably changeset closed
-                    uploadChanges(edit, remoteChanges, true)
+                    uploadChanges(edit, localChanges, false)
                 }
+                // changeset already too large -> try again with new changeset
+                catch (e: ChangesetTooLargeException) {
+                    uploadChanges(edit, localChanges, true)
+                }
+            }
+            // ...but this can fail for various reasons:
+            // - the changeset is already closed on remote
+            // - the element was modified on remote in the meantime
+            // - there's a conflict when applying the change to the locally cached element
+            // - the element does not exist in the local database (cache was deleted)
+            //
+            // In any case -> try again with remote data
+            catch (e: ConflictException) {
+                uploadUsingRemoteRepo(edit, getIdProvider)
             }
         }
     }

+    /**
+     *  Apply the given edit to data downloaded ad-hoc from remote, then upload it.
+     *
+     *  @throws ConflictException if element has been changed on remote in an incompatible way
+     * */
+    private suspend fun uploadUsingRemoteRepo(edit: ElementEdit, getIdProvider: () -> ElementIdProvider): MapDataUpdates {
+        // If a conflict is thrown here, it definitely means that the element has been changed on
+        // remote in an incompatible way. So, we don't catch the exception but exit
+        val remoteChanges = edit.action.createUpdates(RemoteMapDataRepository(mapDataApi), getIdProvider())
+
+        return try {
+            uploadChanges(edit, remoteChanges, false)
+        }
+        // probably changeset was closed -> try again once with new changeset
+        catch (e: ConflictException) {
+            uploadChanges(edit, remoteChanges, true)
+        }
+        // changeset too large -> also try again once with new changeset
+        catch (e: ChangesetTooLargeException) {
+            uploadChanges(edit, remoteChanges, true)
+        }
+    }
+
     private suspend fun uploadChanges(
         edit: ElementEdit,
         changes: MapDataChanges,
@@ -59,6 +87,6 @@ class ElementEditUploader(
         } else {
             changesetManager.getOrCreateChangeset(edit.type, edit.source, edit.position, edit.isNearUserLocation)
         }
-        return mapDataApi.uploadChanges(changesetId, changes, ApplicationConstants.IGNORED_RELATION_TYPES)
+        return mapDataApi.uploadChanges(changesetId, changes, IGNORED_RELATION_TYPES)
     }
 }
diff --git a/app/src/main/java/de/westnordost/streetcomplete/data/osm/mapdata/MapDataApiClient.kt b/app/src/main/java/de/westnordost/streetcomplete/data/osm/mapdata/MapDataApiClient.kt
index a58146812ae..ba8d0827db4 100644
--- a/app/src/main/java/de/westnordost/streetcomplete/data/osm/mapdata/MapDataApiClient.kt
+++ b/app/src/main/java/de/westnordost/streetcomplete/data/osm/mapdata/MapDataApiClient.kt
@@ -40,6 +40,8 @@ class MapDataApiClient(
      *                           is not the same as the one uploading the change
      * @throws AuthorizationException if the application does not have permission to edit the map
      *                                (OAuth scope "write_api")
+     * @throws ChangesetTooLargeException when the [changes] don't fit into the changeset with the given
+     *                               [changesetId] anymore.
      * @throws ConnectionException if a temporary network connection problem occurs
      *
      * @return the updated elements
@@ -60,7 +62,7 @@ class MapDataApiClient(
             return createMapDataUpdates(changedElements, updates, ignoreRelationTypes)
         } catch (e: ClientRequestException) {
             when (e.response.status) {
-                // current element version is outdated, current changeset has been closed already
+                // current element version is outdated or current changeset has been closed already
                 HttpStatusCode.Conflict,
                 // an element referred to by another element does not exist (anymore) or was redacted
                 HttpStatusCode.PreconditionFailed,
@@ -70,6 +72,9 @@ class MapDataApiClient(
                 HttpStatusCode.NotFound -> {
                     throw ConflictException(e.message, e)
                 }
+                HttpStatusCode.PayloadTooLarge -> {
+                    throw ChangesetTooLargeException(e.message, e)
+                }
                 else -> throw e
             }
         }
@@ -209,3 +214,8 @@ data class MapDataChanges(
 sealed interface ElementUpdateAction
 data class UpdateElement(val newId: Long, val newVersion: Int) : ElementUpdateAction
 data object DeleteElement : ElementUpdateAction
+
+/** While adding changes to our changeset, the API reports that the changeset limit is already
+ *  reached. We must create a new changeset */
+class ChangesetTooLargeException(message: String? = null, cause: Throwable? = null) :
+    RuntimeException(message, cause)

diff --git a/app/src/test/java/de/westnordost/streetcomplete/data/osm/edits/upload/ElementEditUploaderTest.kt b/app/src/test/java/de/westnordost/streetcomplete/data/osm/edits/upload/ElementEditUploaderTest.kt
index a41ed651588..6d904c1f40c 100644
--- a/app/src/test/java/de/westnordost/streetcomplete/data/osm/edits/upload/ElementEditUploaderTest.kt
+++ b/app/src/test/java/de/westnordost/streetcomplete/data/osm/edits/upload/ElementEditUploaderTest.kt
@@ -4,19 +4,23 @@ import de.westnordost.streetcomplete.data.ConflictException
 import de.westnordost.streetcomplete.data.osm.edits.ElementEdit
 import de.westnordost.streetcomplete.data.osm.edits.ElementEditAction
 import de.westnordost.streetcomplete.data.osm.edits.upload.changesets.OpenChangesetsManager
+import de.westnordost.streetcomplete.data.osm.mapdata.ChangesetTooLargeException
 import de.westnordost.streetcomplete.data.osm.mapdata.MapDataApiClient
 import de.westnordost.streetcomplete.data.osm.mapdata.MapDataChanges
 import de.westnordost.streetcomplete.data.osm.mapdata.MapDataController
 import de.westnordost.streetcomplete.data.osm.mapdata.MapDataUpdates
 import de.westnordost.streetcomplete.testutils.any
+import de.westnordost.streetcomplete.testutils.eq
 import de.westnordost.streetcomplete.testutils.mock
 import de.westnordost.streetcomplete.testutils.on
 import kotlinx.coroutines.runBlocking
 import org.mockito.ArgumentMatchers.anyBoolean
 import org.mockito.ArgumentMatchers.anyLong
 import org.mockito.Mockito.doThrow
+import org.mockito.Mockito.verify
 import kotlin.test.BeforeTest
 import kotlin.test.Test
+import kotlin.test.assertEquals
 import kotlin.test.assertFailsWith

 class ElementEditUploaderTest {
@@ -34,6 +38,29 @@ class ElementEditUploaderTest {
         uploader = ElementEditUploader(changesetManager, mapDataApi, mapDataController)
     }

+    @Test fun `create new changeset when changeset is too large`(): Unit = runBlocking {
+        val edit: ElementEdit = mock()
+        val action: ElementEditAction = mock()
+        on(edit.action).thenReturn(action)
+        on(action.createUpdates(any(), any())).thenReturn(MapDataChanges())
+
+        // current changeset is 1
+        on(changesetManager.getOrCreateChangeset(any(), any(), any(), anyBoolean())).thenReturn(1L)
+        // but when uploading using this changeset, exception is thrown
+        on(mapDataApi.uploadChanges(eq(1L), any(), any())).thenThrow(ChangesetTooLargeException())
+
+        // creating a changeset yields id 2
+        on(changesetManager.createChangeset(any(), any(), any())).thenReturn(2)
+        // and uploading changes to this changeset yields some result
+        val mapDataUpdates = MapDataUpdates()
+        on(mapDataApi.uploadChanges(eq(2L), any(), any())).thenReturn(mapDataUpdates)
+
+        assertEquals(
+            mapDataUpdates,
+            uploader.upload(edit, { mock() })
+        )
+    }
+
     @Test fun `passes on conflict exception`(): Unit = runBlocking {
         val edit: ElementEdit = mock()
         val action: ElementEditAction = mock()


OsmNoteQuestController: surveyme override and regex test update

Resolves #6052.

* Restructured exclusion of notes that get overridden by #surveyme
* Added RegexOption.DOT_MATCHES_ALL to function NoteComment.containsSurveyRequiredMarker: #surveyme wasn't found at the end of the text added by the user, because SC add "\n\nvia StreetComplete <x.y>" to the text

#surveyme in last Note comment should show the Note (even if there are no preceding comments)
Back in https://github.com/streetcomplete/StreetComplete/issues/2641 (via https://github.com/streetcomplete/StreetComplete/commit/b3ad39b2130dabdf0db2132dabeb3eaaa2ad48c4), it was made that `#surveyme` tag in the last comment would always (unless manually hidden by the user) result in showing the OSM Note, even if that comment was created by the same user which is using the app.

In SC 60.0 it works *mostly*,  i.e. if that comment is any ***but*** the first one.

I don't think that is intentional (I see no logic for that, nor do the comments seem to indicate the reason for such strange behaviour).
If the user's **last comment** contains a marker indicating the survey is required and we want to display the note in that case (as we do), then it **_should not matter how many comments preceded that last one_** (0 or more).

**How to Reproduce**
1) add a comment to existing note with just text `test`; and note gets hidden - OK
2) add a comment to existing note with text `#surveyme test`; and note remains displayed - OK
3) create a new note with text `#surveyme test` -- note gets hidden - BAD

**Expected Behavior**
- in step (3), note should remain displayed just as in does in (2), as the last message indicates the survey is required.

**Versions affected**
SC 60.0, Android 14

This behaviour is coordinated by

https://github.com/streetcomplete/StreetComplete/blob/45a51eb3260babab935c1a56e7e23c4679be581a/app/src/main/java/de/westnordost/streetcomplete/data/osmnotes/notequests/OsmNoteQuestController.kt#L191-L223

more specifically

https://github.com/streetcomplete/StreetComplete/blob/45a51eb3260babab935c1a56e7e23c4679be581a/app/src/main/java/de/westnordost/streetcomplete/data/osmnotes/notequests/OsmNoteQuestController.kt#L207

This hides a note that is created from (a) within SC, (b) by yourself and (c) has no replies, just like your test (3), no matter if #surveyme is contained or not.

A note I created via osm.org and downloaded the data in SC after that correctly shows the note, even though #surveyme is the first comment:

![image](https://github.com/user-attachments/assets/80b67d33-3606-4f6a-8577-ca70c496b664)

So, it would need to be discussed, whether the exclusion of notes from oneself within the app without replies should be hidden no matter what, or, if surveyme should "override" that behaviour.
IMHO, I can live with the behaviour as is.
Thanks for the investigation!
I'd agree that the current behavior is as intended. The reason is stated in the comment:

newly created notes by user should not be shown if it was both created in this app and has no replies yet
> So, it would need to be discussed, whether the exclusion of notes from oneself within the app without replies should be hidden no matter what, or, if surveyme should "override" that behaviour.

I see no reason why we should exclude them? `#surveyme` (especially if it has no following comments) clearly indicates that on-site survey is needed (And since the note was left in SC, also indicates that it cannot be surveyed on the ground **at that moment**, e.g. because being outside of opening hours or other limitations).

Original note creator can do that on-ground survey as well as any other on-the ground mapper (and probably better, as they obviously have active interest in that being solved, or they wouldn't create the note in the first place).

> newly created notes by user should not be shown if it was both created in this app and has no replies yet

Yes, but that is pre-surveyme behaviour, and should be overriden by `#surveyme`?

Same as we regularly don't show the note if it was last commented by current user -- but that check ***is*** currently being overriden by `#surveyme` (and the same logic why it is overriden in case when it is second or third comment, should apply when it is a first comment).
Hmm, okay, but if `#surveyme` in the starter post when posted by yourself in this app should not lead to it being hidden for you, it means that if you create a note with this text, immediately a quest pin will appear that asks you if you can contribute anything to that note. Usually, when one writes a note, it isn't immediately visible again for the user that created it.

diff --git a/app/src/main/java/de/westnordost/streetcomplete/data/osmnotes/notequests/OsmNoteQuestController.kt b/app/src/main/java/de/westnordost/streetcomplete/data/osmnotes/notequests/OsmNoteQuestController.kt
index ad4fadbd8c7..4894f5b6b80 100644
--- a/app/src/main/java/de/westnordost/streetcomplete/data/osmnotes/notequests/OsmNoteQuestController.kt
+++ b/app/src/main/java/de/westnordost/streetcomplete/data/osmnotes/notequests/OsmNoteQuestController.kt
@@ -196,16 +196,22 @@ private fun Note.shouldShowAsQuest(
     // don't show notes hidden by user
     if (id in blockedNoteIds) return false

-    // don't show notes where user replied last unless he wrote a survey required marker
-    if (comments.last().isReplyFromUser(userId)
+    /*
+        We usually don't show notes where either the user is the last responder, or the
+        note was created with the app and has no replies.
+        If the most recent comment contains "#surveyme", we want to show the note regardless.
+        See https://github.com/streetcomplete/StreetComplete/issues/6052#issuecomment-2567163451
+     */
+    if (
+        (
+            comments.last().isReplyFromUser(userId) ||
+            (probablyCreatedByUserInThisApp(userId) && !hasReplies)
+        )
         && !comments.last().containsSurveyRequiredMarker()
     ) {
         return false
     }

-    // newly created notes by user should not be shown if it was both created in this app and has no replies yet
-    if (probablyCreatedByUserInThisApp(userId) && !hasReplies) return false
-
     /*
         many notes are created to report problems on the map that cannot be resolved
         through an on-site survey.
@@ -250,7 +256,7 @@ private fun Note.containsSurveyRequiredMarker(): Boolean =
     comments.any { it.containsSurveyRequiredMarker() }

 private fun NoteComment.containsSurveyRequiredMarker(): Boolean =
-    text?.matches(".*#surveyme.*".toRegex()) == true
+    text?.contains("#surveyme", ignoreCase = true) == true

 private fun Note.probablyCreatedByUserInThisApp(userId: Long): Boolean {
     val firstComment = comments.first()

diff --git a/app/src/test/java/de/westnordost/streetcomplete/data/osmnotes/notequests/OsmNoteQuestControllerTest.kt b/app/src/test/java/de/westnordost/streetcomplete/data/osmnotes/notequests/OsmNoteQuestControllerTest.kt
index 5b347b3a33a..9200a73b9d3 100644
--- a/app/src/test/java/de/westnordost/streetcomplete/data/osmnotes/notequests/OsmNoteQuestControllerTest.kt
+++ b/app/src/test/java/de/westnordost/streetcomplete/data/osmnotes/notequests/OsmNoteQuestControllerTest.kt
@@ -175,6 +175,33 @@ class OsmNoteQuestControllerTest {
         assertNotNull(ctrl.getVisible(1))
     }

+    @Test fun `get note quest created in app without comments and without survey required marker returns null`() {
+        on(noteSource.get(1)).thenReturn(note(comments = listOf(
+            comment(text = "this is a non-question test note\n\nCreated via StreetComplete 60.1", user = User(id = 100, "Blaubär")),
+        )))
+        on(userDataSource.userId).thenReturn(1)
+
+        assertNull(ctrl.getVisible(1))
+    }
+
+    @Test fun `get note quest created in app without comments and with survey required marker returns non-null`() {
+        on(noteSource.get(1)).thenReturn(note(comments = listOf(
+            comment(text = "this is a non-question test note #surveyme\n\nCreated via StreetComplete 60.1", user = User(id = 100, "Blaubär")),
+        )))
+        on(userDataSource.userId).thenReturn(1)
+
+        assertNotNull(ctrl.getVisible(1))
+    }
+
+    @Test fun `get note quest created in app without comments and with survey required marker (ignore case) returns non-null`() {
+        on(noteSource.get(1)).thenReturn(note(comments = listOf(
+            comment(text = "this is a non-question test note #SurVEyMe\n\nCreated via StreetComplete 60.1", user = User(id = 100, "Blaubär")),
+        )))
+        on(userDataSource.userId).thenReturn(1)
+
+        assertNotNull(ctrl.getVisible(1))
+    }
+
     @Test fun `get quest not phrased as question returns null`() {
         on(noteSource.get(1)).thenReturn(note(comments = listOf(
             comment(text = "test")


prohibited for pedestrians: clear sidewalk tags when user answers that there is a sidewalk

…t there is a sidewalk (fixes #5952)

"Actually, there is a sidewalk, but it is displayed separately on the map." - applied answer may be wrong, sidewalk may be only on one side
`quest_accessible_for_pedestrians_separate_sidewalk` has

> Actually, there is a sidewalk, but it is displayed separately on the map.

wording.

If selected https://github.com/streetcomplete/StreetComplete/blob/9f6475f25b70e264e35f803c8cb19dba0b81fb1d/app/src/main/java/de/westnordost/streetcomplete/quests/foot/AddProhibitedForPedestrians.kt#L63 will apply `sidewalk:both=separate`

But sidewalk may be actually only on one side. Maybe adding `sidewalk=separate` would be better? Or just remove existing `sidewalk*` tags?

`sidewalk=separate` is invalid tagging, as far as I know.

But yeah, the `sidewalk` tag could just be removed. The wording of the other answer would then also need to be changed to "Actually, there is a sidewalk". However, note that the sidewalk quest is disabled by default.
> sidewalk=separate is invalid tagging, as far as I know.

AFAIK it is fine, and while `:left` `:right` `:both` may be preferred for extra detail (`sidewalk=separate` means that sidewalk is at one side, or both), this is also accepted and in use and understood

see https://taginfo.openstreetmap.org/tags/sidewalk=separate#overview
Right, then I remember it wrong. Care to make the change yourself?
Actually, I like my proposal better. It also reduces unnecessary text length and gives the user the opportunity to specify it more precisely afterwards. (StreetComplete interprets `sidewalk=separate` as `sidewalk:both=separate`, and so do all other data consumers)

./gradlew :app:testDebugUnitTest --tests "de.westnordost.streetcomplete.quests.foot.AddProhibitedForPedestriansTest"

diff --git a/app/src/main/java/de/westnordost/streetcomplete/quests/foot/AddProhibitedForPedestrians.kt b/app/src/main/java/de/westnordost/streetcomplete/quests/foot/AddProhibitedForPedestrians.kt
index 67799039e50..dae80bb78ff 100644
--- a/app/src/main/java/de/westnordost/streetcomplete/quests/foot/AddProhibitedForPedestrians.kt
+++ b/app/src/main/java/de/westnordost/streetcomplete/quests/foot/AddProhibitedForPedestrians.kt
@@ -8,7 +8,7 @@ import de.westnordost.streetcomplete.data.user.achievements.EditTypeAchievement.
 import de.westnordost.streetcomplete.osm.ROADS_ASSUMED_TO_BE_PAVED
 import de.westnordost.streetcomplete.osm.Tags
 import de.westnordost.streetcomplete.osm.surface.PAVED_SURFACES
-import de.westnordost.streetcomplete.quests.foot.ProhibitedForPedestriansAnswer.HAS_SEPARATE_SIDEWALK
+import de.westnordost.streetcomplete.quests.foot.ProhibitedForPedestriansAnswer.ACTUALLY_HAS_SIDEWALK
 import de.westnordost.streetcomplete.quests.foot.ProhibitedForPedestriansAnswer.NO
 import de.westnordost.streetcomplete.quests.foot.ProhibitedForPedestriansAnswer.YES

@@ -59,9 +59,9 @@ class AddProhibitedForPedestrians : OsmFilterQuestType<ProhibitedForPedestriansA
             // the question is whether it is prohibited, so YES -> foot=no etc
             YES -> tags["foot"] = "no"
             NO -> tags["foot"] = "yes"
-            HAS_SEPARATE_SIDEWALK -> {
-                tags["sidewalk:both"] = "separate"
-                // wrong tagging may exist, it should be removed to prevent quest from reappearing
+            // but we did not specify on which side. So, clear it, sidewalk is added separately
+            ACTUALLY_HAS_SIDEWALK -> {
+                tags.remove("sidewalk:both")
                 tags.remove("sidewalk")
                 tags.remove("sidewalk:left")
                 tags.remove("sidewalk:right")
diff --git a/app/src/main/java/de/westnordost/streetcomplete/quests/foot/AddProhibitedForPedestriansForm.kt b/app/src/main/java/de/westnordost/streetcomplete/quests/foot/AddProhibitedForPedestriansForm.kt
index 674ab40e280..a29759ef739 100644
--- a/app/src/main/java/de/westnordost/streetcomplete/quests/foot/AddProhibitedForPedestriansForm.kt
+++ b/app/src/main/java/de/westnordost/streetcomplete/quests/foot/AddProhibitedForPedestriansForm.kt
@@ -3,7 +3,7 @@ package de.westnordost.streetcomplete.quests.foot
 import de.westnordost.streetcomplete.R
 import de.westnordost.streetcomplete.quests.AListQuestForm
 import de.westnordost.streetcomplete.quests.TextItem
-import de.westnordost.streetcomplete.quests.foot.ProhibitedForPedestriansAnswer.HAS_SEPARATE_SIDEWALK
+import de.westnordost.streetcomplete.quests.foot.ProhibitedForPedestriansAnswer.ACTUALLY_HAS_SIDEWALK
 import de.westnordost.streetcomplete.quests.foot.ProhibitedForPedestriansAnswer.NO
 import de.westnordost.streetcomplete.quests.foot.ProhibitedForPedestriansAnswer.YES

@@ -12,6 +12,6 @@ class AddProhibitedForPedestriansForm : AListQuestForm<ProhibitedForPedestriansA
     override val items = listOf(
         TextItem(YES, R.string.quest_accessible_for_pedestrians_prohibited),
         TextItem(NO, R.string.quest_accessible_for_pedestrians_allowed),
-        TextItem(HAS_SEPARATE_SIDEWALK, R.string.quest_accessible_for_pedestrians_separate_sidewalk),
+        TextItem(ACTUALLY_HAS_SIDEWALK, R.string.quest_accessible_for_pedestrians_sidewalk),
     )
 }
diff --git a/app/src/main/java/de/westnordost/streetcomplete/quests/foot/ProhibitedForPedestriansAnswer.kt b/app/src/main/java/de/westnordost/streetcomplete/quests/foot/ProhibitedForPedestriansAnswer.kt
index c632d131b64..469fc27299e 100644
--- a/app/src/main/java/de/westnordost/streetcomplete/quests/foot/ProhibitedForPedestriansAnswer.kt
+++ b/app/src/main/java/de/westnordost/streetcomplete/quests/foot/ProhibitedForPedestriansAnswer.kt
@@ -3,5 +3,5 @@ package de.westnordost.streetcomplete.quests.foot
 enum class ProhibitedForPedestriansAnswer {
     YES,
     NO,
-    HAS_SEPARATE_SIDEWALK
+    ACTUALLY_HAS_SIDEWALK
 }
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 58b9d2d851a..dcd837d8b22 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -666,7 +666,7 @@ Before uploading your changes, the app checks with a <a href=\"https://www.we
     <string name="quest_accessible_for_pedestrians_title_prohibited">Are pedestrians forbidden to walk on this road without sidewalk here?</string>
     <string name="quest_accessible_for_pedestrians_prohibited">It is forbidden.</string>
     <string name="quest_accessible_for_pedestrians_allowed">It is allowed.</string>
-    <string name="quest_accessible_for_pedestrians_separate_sidewalk">Actually, there is a sidewalk, but it is displayed separately on the map.</string>
+    <string name="quest_accessible_for_pedestrians_sidewalk">Actually, there is a sidewalk.</string>

     <string name="quest_address_title">"What’s the house number of this building?"</string>
     <string name="quest_address_house_number_label">House number:</string>

diff --git a/app/src/test/java/de/westnordost/streetcomplete/quests/foot/AddProhibitedForPedestriansTest.kt b/app/src/test/java/de/westnordost/streetcomplete/quests/foot/AddProhibitedForPedestriansTest.kt
index 4766d2bafeb..60f357b2f41 100644
--- a/app/src/test/java/de/westnordost/streetcomplete/quests/foot/AddProhibitedForPedestriansTest.kt
+++ b/app/src/test/java/de/westnordost/streetcomplete/quests/foot/AddProhibitedForPedestriansTest.kt
@@ -27,22 +27,15 @@ class AddProhibitedForPedestriansTest {
         )
     }

-    @Test fun `apply separate sidewalk answer`() {
-        assertEquals(
-            setOf(StringMapEntryAdd("sidewalk:both", "separate")),
-            questType.answerApplied(HAS_SEPARATE_SIDEWALK)
-        )
-    }
-
-    @Test fun `remove wrong sidewalk tagging`() {
+    @Test fun `apply actually sidewalk answer clears all sidewalk tagging`() {
         assertEquals(
             setOf(
-                StringMapEntryModify("sidewalk:both", "yes", "separate"),
+                StringMapEntryDelete("sidewalk:both", "yes"),
                 StringMapEntryDelete("sidewalk:left", "yes"),
                 StringMapEntryDelete("sidewalk:right", "yes"),
                 StringMapEntryDelete("sidewalk", "both"),
             ),
-            questType.answerAppliedTo(HAS_SEPARATE_SIDEWALK, mapOf(
+            questType.answerAppliedTo(ACTUALLY_HAS_SIDEWALK, mapOf(
                 "sidewalk" to "both",
                 "sidewalk:left" to "yes",
                 "sidewalk:right" to "yes",