Repository: streetcomplete/StreetComplete (language: kotlin)

Issue:
Multiplatform OSM API client
Requires #5408 to be done first.

The [OSM API](https://wiki.openstreetmap.org/wiki/API_v0.6) is a HTTP REST API that communicates chiefly in XML. This must be ported to Kotlin multiplatform. 

Currently, the Java library [`de.westnordost.osmapi`](https://github.com/westnordost/osmapi) is used for any communication with the OSM API 0.6, i.e. uploading map data, notes and gpx tracks, downloading map data, notes, user data, etc.

The dependency to `osmapi` must be replaced with an implementation based on

- ideally the http client from #5408 and

- a multiplatform XML parser / writer, which is stable, well-tested and ideally (semi-)official / well-maintained and *ideally* but not necessarily compatible with `kotlinx-serialization`. This is also important for other XML parsing in the app, i.e. #5369

- using `kotlinx-datetime` to parse timestamps (already a dependency)

See interfaces [`TracksApi`](https://github.com/search?q=repo%3Astreetcomplete%2FStreetComplete+TracksApi.kt&type=code), [`MapDataApi`](https://github.com/search?q=repo%3Astreetcomplete%2FStreetComplete+MapDataApi.kt&type=code) and [`NotesApi`](https://github.com/search?q=repo%3Astreetcomplete%2FStreetComplete+MapDataApi.kt&type=code)

### XML Parser / Writer requirements

- multiplatform support for at least Java/Android and iOS

- stable, well-tested and ideally (semi-)official  / well-maintained

#### xmlutil

There is currently one xml serializer compatible with kotlinx-serialization: [`io.github.pdvrieze.xmlutil`](https://github.com/pdvrieze/xmlutil). It is also mentioned in the Ktor docs, i.e. is the XML serializer/deserializer used in Ktor.

I didn't find any other multiplatform XML parsers/writers.
Highlight `traffic_sign=city_limit` when answering maxspeed quest
**Use case**
In many countries, city limit signs define maxspeed rules. When answering the maxspeed quest, the precise location of the an already tagged `traffic_sign=city_limit` is important to keep the data consistent. For instance, when splitting ways to answer the maxspeed quest, the split should happen at the node that's the city limit.

**Proposed Solution**
Show the locations of [`traffic_sign=city_limit`](https://wiki.openstreetmap.org/wiki/Tag:traffic_sign%3Dcity_limit) on the map when the maxspeed quest is being answered.

**Caveats**

- `traffic_sign=city_limit` is [very well mapped in Germany](https://taginfo.geofabrik.de/europe:germany/tags/traffic_sign=city_limit#overview). Usage of this tag may be more rare in other countries.
- In some countries (for instance Poland) `traffic_sign=city_limit` does not imply maxspeed. In these countries highlighting the city limit is probably more confusing than helpful.
- One could also consider to highlight more `traffic_sign` values that imply or regulate maxspeed, e.g. [`traffic_sign=DE:274[70]`](https://taginfo.openstreetmap.org/tags/traffic_sign=DE:274%5B70%5D#overview). Not sure how common those variants are and whether maintaing a list of those is worth the effort.
Don't use disused:shop=yes on non-shops
<!-- 
Please provide a clear and concise description of the bug. 

Maybe it is not a bug? Check the FAQ: https://wiki.openstreetmap.org/wiki/StreetComplete/FAQ
-->

When an amenity, healthcare facility, etc. is closed, SC adds `disused:shop=yes`. See, for example, [here](https://www.openstreetmap.org/node/7043155278/history). This is adding wrong data, as the lifecycle prefix should match the former tag.

**How to Reproduce**
<!-- Add steps to reproduce this behavior here -->
I guess this happens every time that you mark a location as vacant.

**Expected Behavior**
<!-- Add a clear and concise description of what you expected to happen -->
The (former) main tag of the facility should be altered by adding a `disused:` in front, like [explained in the wiki](https://wiki.openstreetmap.org/wiki/Lifecycle_prefix).

**Versions affected**
<!-- Add the Android and StreetComplete version here. Please use exact version number instead of saying "latest" -->
SC 56.0, probably also later, there was no mention in the changelogs.



Discussion hints:
I would like to pick this up. I will ping back after some basic implementation. 
This is blocked by #5408, i.e. that should be done first.
Because, the thinking is the following:

1. Both the access to the OSM API and the other APIs we are accessing should be done with the same library in order to both reduce the number of dependencies and to reduce the amount of brain-work to familiarize oneself with a library

2. It would be possible to write a full kotlin multiplatform or iOS replacement of the Java library [de.westnordost.osmapi](https://github.com/westnordost/osmapi) instead, in a separate repository. de.westnordost.osmapi covers the whole of the OSM API. But re-implementing that library would be considerably more work than just implementing the (few) OSM API calls we actually use, plus, more continuous maintenance work, because the OSM API has been extended over the years, so the expectation is that if a kotlin multplatform or even an iOS version of that library is created, it will be maintained equally well. (And if it is not maintained, then maybe we don't even want to use it in STreetComplete, because we have had bad experiences with using libraries that were badly maintained or were stopped being maintained)
My intention is to build an equivalent of de.westnordost.osmapi so that it can be used in multiplatform. Using Ktor client seems a viable option and that can be extended for other API calls within the project. I understand that the maintenance will take its toll but I beleive Ktor is here to stay. Only caveat we found was with the deserialization of xml. Though ktor provides a library, its only JVM compatible. With [io.github.pdvrieze.xmlutil](https://github.com/pdvrieze/xmlutil), I think it makes a better sense. Let me know if we have decided on HTTP Client 
Cool! Indeed. Yes, xmlutil should work. The only http client that is multiplatform also for native is Ktor Client, as far as I know, so the decision is to use Ktor. OkHttp 5 is only multiplatform jvm+js.
@westnordost we started off simple in an independent repository. I gave you read access. We can move it to any other repo once its in a decent shape
I can have a look at it when it is public, if you like, though bear in mind that I have zero experience with Ktor Client so far. The OSM API on the other hand I know inside-out.
We have come to a decent shape on the api. We may need help in how to publish it for using in other projects. This may be a bit tricky.
Not sure what you mean.

Java/Kotlin FOSS libraries are generally published on maven central via sonatype. If you are not sure what to write into the build gradle, I recently published a multiplatform library too, check https://github.com/westnordost/osm-opening-hours/blob/master/build.gradle.kts
If I remember correctly, you need an account at sonatype to be able to publish artifacts, you'll need to read a guide for that because it is too long ago that I did that.

If it is regarding the license: Any open source license would do, permissive or LGPL, even GPLv3 would be fine.
The code is almost ready to be made public. @westnordost  need advice on the package name though. right now its `com.example.osmapi` . What do you suggest?
in.susrisha.osmapi ? 

Or, com.<nameofyourcompany>.osmapi?

El 16 de febrero de 2024 11:36:44 CET, Naresh Kumar D ***@***.***> escribió:
>The code is almost ready to be made public. @westnordost  need advice on the package name though. right now its `com.example.osmapi` . What do you suggest?
>
>-- 
>Reply to this email directly or view it on GitHub:
>https://github.com/streetcomplete/StreetComplete/issues/5410#issuecomment-1948134443
>You are receiving this because you were mentioned.
>
>Message ID: ***@***.***>
Its hosted under vindago org. So, we will name it com.vindago.osmapi Thanks for the suggestion
In that case, in.vindago.osmapi would be more correct (as you don't own vindago.com)

El 16 de febrero de 2024 12:30:24 CET, Naresh Kumar D ***@***.***> escribió:
>Its hosted under vindago org. So, we will name it com.vindago.osmapi Thanks for the suggestion
>
>-- 
>Reply to this email directly or view it on GitHub:
>https://github.com/streetcomplete/StreetComplete/issues/5410#issuecomment-1948222717
>You are receiving this because you were mentioned.
>
>Message ID: ***@***.***>
@westnordost here is the public link to the API library. its not complete yet but its a start. https://github.com/vindagollc/osm-api/
Do have a look at the dev branch
Ah, cool, interesting! This is actually a fork/port of the Java library, so this clears the license question, it must be licensed under the identical license as the java library. Would you add the license information, too?

I can have a more thorough look through it later. Do you prefer I create issues for everything I find, or rather one issue in which I bundle the "review". Or some other method? It would be nice if there was a way where I could easily comment on and make suggestions on single lines. Maybe make a PR from master to a new empty branch that does not contain anything yet, so everything is a diff?

From a cursory look, it looks quite complete already! I haven't looked at the code in detail, and there seem to be still some open TODOs / WIP, but the overall structure looks good! That must have been quite a lot of work!

Some thoughts:

- there are still a few example files around, e.g. Greeting.kt. Also, the OsmConnection class has some hardcoded basic authentication in there

- the tests look incomplete. If you basically copied the Java library, couldn't you reuse (& convert to kotlin) the tests from the Java library instead of implementing them from scratch? If I remember correctly, the test coverage of the Java library has been very good

- I see despite using kotlinx-serialization / io.github.pdvrieze.xmlutil you parse (and generate?) the XML response from the API by hand. With kotlinx-serialization it is not necessary (nor recommended, as far as I know) to do parsing by hand. You can simply annotate the data model how it should be (de)serialized. See https://kotlinlang.org/docs/serialization.html#serialize-and-deserialize-json for JSON, I would expect it to be somewhat similar for XML. Did you not know this, or is there some reason why you chose to parse it by hand?
  (Using this annotation-based approach not only reduced the XML parsing code, it should then of course reduce or eliminate the need for tests concerning the XML parsing)

- Most data classes should probably rather be `data class`es. It is uncommon to have `open class`es.

- I created the Java library almost ten years ago, in Java. There are a few things I would have done differently today, plus, since Kotlin is another language which has other structures and best practices and you use another library for communication with the OSM Api, it would make sense to do a few things differently architecture-wise. From the top of my head (i.e. incomplete) and without thoroughly thinking about it - so take it with a grain of salt:
  - OsmConnection used to hold/bundle the information necessary and the common logic necessary to make calls to the OSM API. I think with ktor http one would basically use HttpClient instead, because on the http client, you can already set the default user agent, default timeout etc. For common logic, static internal functions seem to be the better option.
  - The "Handler" classes is a callback-based API. In Kotlin, especially with coroutines, one would use `Flow`s for that instead. Flows is an interesting topic, it's a bit of a read, but they are very interesting and helpful. The Ktor HttpClient being deep in the Kotlin world, I would expect that there is already something that facilitates this
  - That LatLon is an interface... I don't remember why I did that, probably because there used to be a `Fixed1e7LatLon` implementation to "save space" before I noticed that it was really slow. I think LatLon could just be a data class.
  - I am not sure if I would have created all those custom exception classes for different HTTP status codes if I reimplemented the library now. It has been a lot of work to maintain to keep up to date with current behavior of the OSM API as new status codes were introduced / documented. I expect it to continue to be an (unnecessary) effort

- The port/fork looks very similar to the osmapi java library architecture-wise. I wonder if it would be an option to make the KMP version, once finished, the next major version of the java library to reduce maintenance effort overall(?) (no guarantees, not sure how much this library would differ from the original compatibility/migration-wise

If you want to avoid using the value because it could be incorrect, as @matkoniecz argued in https://github.com/streetcomplete/StreetComplete/issues/2707#issuecomment-816471983, at least the key should be preserved, e.g. `disused:amenity=yes`.
This is more of a missing feature than a bug, but I agree, it should be done. (Or, use `shop=vacant` after all.)

Unfortunately, the code for this is quite scattered right now and assumes that tags like `disused:amenity` (...office, club, craft, ... ) are not used, so this all should be handled in a newly to be defined function named e.g. `fun makeDisused(tags: Tag)` or similar in `de/westnordost/osm/Lifecycle.kt` (or similar).

(If someone wants to contribute this, start with searching for usages of "disused:shop" in the code.)
> This is more of a missing feature than a bug, but I agree, it should be done. (Or, use shop=vacant after all.)

If `disused:shop=yes` is bad in case of emptied amenity then surely `shop=vacant` is also bad?

For shop-like amenities `disused:shop=yes` seems not worse than `disused:amenity=yes` to me.
For disused amenities I think it's be better to keep the value. `disused:amenity=yes` could also be a disused waste bin, vending machine or car park.
That runs into problem that `disused:amenity=doctor` claims that there are still visible remains of doctor office there.

StreetComplete user is not asked about that.

So it should be either `disused:amenity=yes` or `disused:shop=yes` or `shop=vacant`

Or StreetComplete should ask to distinguish between "empty vacant space, traces of former business are gone, or POI changed in meantime and you see traces of a different POI" and "you can still see remains of XYZ business". But that seems far too complex and low value.
> That runs into problem that disused:amenity=doctor claims that there are still visible remains of doctor office there.

Can you link to the documentation that claims this? I didn't know that remains of the previous place need to be visible for `disused:shop=*` to be tagged.
OK, they do not be visible by surveyor but they still need to be there. If there are no traces at all then it no longer qualifies. And asking user about this seems to much. See https://wiki.openstreetmap.org/wiki/Key:disused:amenity and https://wiki.openstreetmap.org/wiki/Lifecycle_prefix and https://wiki.openstreetmap.org/wiki/Key:disused:*

(I have not edited too much this pages IIRC)
Right. I read this as - in this case - the shop _space_ still exists.
I would say *shop space* still exists - if everything like signs and equipment was scrubbed and former bakery cannot be distinguished from former doctor office, then it is not say `disused:amenity=doctor`
Er, I am at a loss. Can you cite the documentation that claims this? And if so, how do you propose to project that into StreetComplete?
I also can't find in the documentation that a `disused:` shop or amenity must still be recognizable as this specific kind of feature. 

If we wanted to be very exact whe could, in a case where a user selects that the feature isn't there anymore, ask, if there are any traces left (e.g. a vacant shop) or not (e.g. it has been turned into an apartment). For the latter case you could use `abandoned:` instead. 

But maybe this is more complex than necessary. I don't see a problem if SC used `disused:` in a case where another lifecycle prefix might be more fitting. The main purpose of these prefixes is to remove objects from the map temporarily, it is in my opinion not very important that the exact prefix is used.
> I also can't find in the documentation that a disused: shop or amenity must still be recognizable as this specific kind of feature.

If it is not recognisable anymore as given feature type then it is going to fail "reasonable state of repair but which are currently unused" ( https://wiki.openstreetmap.org/wiki/Key:disused:amenity ) and https://wiki.openstreetmap.org/wiki/Verifiability

Though of all tagging issues this is going to be the least important one.
Right, fine. So, it is irrelevant for us, and so is this discussion. In the app you can state that it is _vacant_ now. If the place was ruined now, i.e. the building it was contained within is ruined, then nobody would answer that it is _vacant_ now.

The cited sentence does not mean that it must be recognizable as an ex-bakery, an ex-doctors' office or whatever.

Pull request: Multiplatform OSM API client
Resolves #5410

---

Also took the opportunity to make the implementations and naming for all the clients that use the multiplatform Ktor-Client for Http (which is everything now) consistent.

---

I did a bit of performance testing. Unfortunately, the results are **very** unsatisfying. 

### Downloading city center of Hamburg (~35000 elements)

(Done on a Samsung S10e, release APK)

![performance](https://github.com/streetcomplete/StreetComplete/assets/4661658/c9f5098d-763a-4225-ac20-849184d7d5bb)

**Edit: The download+parse time for ktor+xmlutil has now been reduced to about 5.2s for this particular test**

The previously used Java library osmapi...
- uses Java's [HttpUrlConnection](https://docs.oracle.com/javase/8/docs/api/java/net/HttpURLConnection.html) to do the Http request
- the response [InputStream](https://docs.oracle.com/javase/8/docs/api/java/io/InputStream.html) is consumed by a [streaming parser](https://developer.android.com/reference/org/xmlpull/v1/XmlPullParser) which produces one OSM element after another
- which in turn each is copied into the app's own data structure 

With this PR, we use
- Ktor-client with CIO engine  
- response body is copied into a string
- the string is parsed by [xmlutil](https://github.com/pdvrieze/xmlutil) into a (DOM) data structure
- we copy the data structure into our own
 
It is clear that the second approach must be slower, especially for large data, because we don't stream anything (not really supported by ktor and xmlutil **just yet**) but always copy the whole data. On the other hand, getting the response body as a string (rather than as a stream) and copying all the data in the end seems to be not be the cause for why it is so slow.
I am (even more) surprised how slow Ktor-client (and/or CIO) is. I see on the log that the garbage collector is quite busy. What is it doing?

Test command: ./gradlew :app:testDebugUnitTest --tests "de.westnordost.streetcomplete.data.user.UserApiParserTest" --tests "de.westnordost.streetcomplete.data.user.statistics.StatisticsApiClientTest" --tests "de.westnordost.streetcomplete.data.osmnotes.edits.NoteEditsUploaderTest" --tests "de.westnordost.streetcomplete.data.osm.edits.upload.changesets.OpenChangesetsManagerTest" --tests "de.westnordost.streetcomplete.data.osmtracks.TracksApiClientTest" --tests "de.westnordost.streetcomplete.data.osm.edits.upload.changesets.ChangesetApiClientTest" --tests "de.westnordost.streetcomplete.data.osm.edits.upload.MapDataUpdatesTest" --tests "de.westnordost.streetcomplete.data.osmnotes.AvatarsDownloaderTest" --tests "de.westnordost.streetcomplete.data.osmtracks.TracksSerializerTest" --tests "de.westnordost.streetcomplete.data.user.statistics.StatisticsDownloaderTest" --tests "de.westnordost.streetcomplete.data.osmnotes.NotesApiClientTest" --tests "de.westnordost.streetcomplete.testutils.OsmDevApi" --tests "de.westnordost.streetcomplete.data.osmnotes.NotesApiParserTest" --tests "de.westnordost.streetcomplete.data.osm.mapdata.MapDataApiParserTest" --tests "de.westnordost.streetcomplete.data.osmnotes.NotesDownloaderTest" --tests "de.westnordost.streetcomplete.data.user.oauth.OAuthAuthorizationTest" --tests "de.westnordost.streetcomplete.data.osm.edits.upload.changesets.ChangesetApiSerializerTest" --tests "de.westnordost.streetcomplete.data.osm.edits.upload.UpdatedElementsHandlerTest" --tests "de.westnordost.streetcomplete.data.osmnotes.StreetCompleteImageUploaderTest" --tests "de.westnordost.streetcomplete.data.osm.edits.upload.ElementEditsUploaderTest" --tests "de.westnordost.streetcomplete.data.osm.edits.upload.ElementEditUploaderTest" --tests "de.westnordost.streetcomplete.data.user.UserApiClientTest" --tests "de.westnordost.streetcomplete.data.osm.mapdata.MapDataApiClientTest" --tests "de.westnordost.streetcomplete.data.osm.mapdata.MapDataApiSerializerTest" --tests "de.westnordost.streetcomplete.data.osmnotes.PhotoServiceApiClientTest"

Fix patch:
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index ec8ffcc7a89..d4e0f21ea61 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -102,14 +102,6 @@ repositories {
     mavenCentral()
 }
 
-configurations {
-    all {
-        // it's already included in Android
-        exclude(group = "net.sf.kxml", module = "kxml2")
-        exclude(group = "xmlpull", module = "xmlpull")
-    }
-}
-
 dependencies {
     val mockitoVersion = "3.12.4"
 
@@ -180,19 +172,16 @@ dependencies {
 
     // HTTP Client
     implementation("io.ktor:ktor-client-core:2.3.12")
-    implementation("io.ktor:ktor-client-cio:2.3.12")
+    implementation("io.ktor:ktor-client-android:2.3.12")
     testImplementation("io.ktor:ktor-client-mock:2.3.12")
+    // TODO: as soon as both ktor-client and kotlinx-serialization have been refactored to be based
+    //       on kotlinx-io, revisit sending and receiving xml/json payloads via APIs, currently it
+    //       is all String-based, i.e. no KMP equivalent of InputStream/OutputStream involved
 
     // finding in which country we are for country-specific logic
     implementation("de.westnordost:countryboundaries:2.1")
     // finding a name for a feature without a name tag
     implementation("de.westnordost:osmfeatures:6.1")
-    // talking with the OSM API
-    implementation("de.westnordost:osmapi-map:3.0")
-    implementation("de.westnordost:osmapi-changesets:3.0")
-    implementation("de.westnordost:osmapi-notes:3.0")
-    implementation("de.westnordost:osmapi-traces:3.1")
-    implementation("de.westnordost:osmapi-user:3.0")
 
     // widgets
     implementation("androidx.viewpager2:viewpager2:1.1.0")
@@ -210,6 +199,7 @@ dependencies {
     // serialization
     implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.1")
     implementation("com.charleskorn.kaml:kaml:0.61.0")
+    implementation("io.github.pdvrieze.xmlutil:core:0.90.1")
 
     // map and location
     implementation("org.maplibre.gl:android-sdk:11.1.0")
diff --git a/app/src/main/java/de/westnordost/streetcomplete/data/ApiClientExceptions.kt b/app/src/main/java/de/westnordost/streetcomplete/data/ApiClientExceptions.kt
new file mode 100644
index 00000000000..c23559dd193
--- /dev/null
+++ b/app/src/main/java/de/westnordost/streetcomplete/data/ApiClientExceptions.kt
@@ -0,0 +1,69 @@
+package de.westnordost.streetcomplete.data
+
+import io.ktor.client.plugins.ClientRequestException
+import io.ktor.client.plugins.ServerResponseException
+import io.ktor.http.HttpStatusCode
+import kotlinx.io.IOException
+import kotlinx.serialization.SerializationException
+
+inline fun <T> wrapApiClientExceptions(block: () -> T): T =
+    try {
+        block()
+    }
+    // server replied with (server) error 5xx
+    catch (e: ServerResponseException) {
+        throw ConnectionException(e.message, e)
+    }
+    // unexpected answer by server -> server issue
+    catch (e: SerializationException) {
+        throw ConnectionException(e.message, e)
+    }
+    // issue with establishing a connection -> nothing we can do about
+    catch (e: IOException) {
+        throw ConnectionException(e.message, e)
+    }
+    // server replied with (client) error 4xx
+    catch (e: ClientRequestException) {
+        when (e.response.status) {
+            // request timeout is rather a temporary connection error
+            HttpStatusCode.RequestTimeout -> {
+                throw ConnectionException(e.message, e)
+            }
+            // rate limiting is treated like a temporary connection error, i.e. try again later
+            HttpStatusCode.TooManyRequests -> {
+                throw ConnectionException(e.message, e)
+            }
+            // authorization is something we can handle (by requiring (re-)login of the user)
+            HttpStatusCode.Forbidden, HttpStatusCode.Unauthorized -> {
+                throw AuthorizationException(e.message, e)
+            }
+            else -> {
+                throw ApiClientException(e.message, e)
+            }
+        }
+    }
+
+/** The server responded with an unhandled error code */
+class ApiClientException(message: String? = null, cause: Throwable? = null)
+    : RuntimeException(message, cause)
+
+/** An error occurred while trying to communicate with an API over the internet. Either the
+ *  connection with the API cannot be established, the server replies with a server error (5xx),
+ *  request timeout (408) or it responds with an unexpected response, i.e. an error occurs while
+ *  parsing the response. */
+class ConnectionException(message: String? = null, cause: Throwable? = null)
+    : RuntimeException(message, cause)
+
+/** While posting an update to an API over the internet, the API reports that our data is based on
+ *  outdated data */
+class ConflictException(message: String? = null, cause: Throwable? = null)
+    : RuntimeException(message, cause)
+
+/** When a query made on an API over an internet would (probably) return a too large result */
+class QueryTooBigException (message: String? = null, cause: Throwable? = null)
+    : RuntimeException(message, cause)
+
+/** An error that indicates that the user either does not have the necessary authorization or
+ *  authentication to execute an action through an API over the internet. */
+class AuthorizationException(message: String? = null, cause: Throwable? = null)
+    : RuntimeException(message, cause)
diff --git a/app/src/main/java/de/westnordost/streetcomplete/data/CommunicationException.kt b/app/src/main/java/de/westnordost/streetcomplete/data/CommunicationException.kt
deleted file mode 100644
index 65ca35eb694..00000000000
--- a/app/src/main/java/de/westnordost/streetcomplete/data/CommunicationException.kt
+++ /dev/null
@@ -1,29 +0,0 @@
-package de.westnordost.streetcomplete.data
-
-/** An error while communicating with an API over the internet occured. */
-open class CommunicationException @JvmOverloads constructor(
-    message: String? = null,
-    cause: Throwable? = null
-) : RuntimeException(message, cause)
-
-/** An error that indicates that the user either does not have the necessary authorization or
- *  authentication to executes an action through an API over the internet. */
-class AuthorizationException @JvmOverloads constructor(
-    message: String? = null,
-    cause: Throwable? = null
-) : CommunicationException(message, cause)
-
-/** An error occurred while trying to communicate with an API over the internet. Either the API is
- *  not reachable or replies with a server error.
- *  Like with an IO error, there is nothing we can do about that other than trying again later. */
-class ConnectionException @JvmOverloads constructor(
-    message: String? = null,
-    cause: Throwable? = null
-) : CommunicationException(message, cause)
-
-/** While posting an update to an API over the internet, the API reports that our data is based on
- *  outdated data */
-class ConflictException @JvmOverloads constructor(
-    message: String? = null,
-    cause: Throwable? = null
-) : CommunicationException(message, cause)
diff --git a/app/src/main/java/de/westnordost/streetcomplete/data/OsmApiModule.kt b/app/src/main/java/de/westnordost/streetcomplete/data/OsmApiModule.kt
index c15f8ec5575..86006563682 100644
--- a/app/src/main/java/de/westnordost/streetcomplete/data/OsmApiModule.kt
+++ b/app/src/main/java/de/westnordost/streetcomplete/data/OsmApiModule.kt
@@ -1,15 +1,16 @@
 package de.westnordost.streetcomplete.data
 
-import de.westnordost.osmapi.OsmConnection
-import de.westnordost.osmapi.user.UserApi
-import de.westnordost.streetcomplete.ApplicationConstants
-import de.westnordost.streetcomplete.data.osm.mapdata.MapDataApi
-import de.westnordost.streetcomplete.data.osm.mapdata.MapDataApiImpl
-import de.westnordost.streetcomplete.data.osmnotes.NotesApi
-import de.westnordost.streetcomplete.data.osmnotes.NotesApiImpl
-import de.westnordost.streetcomplete.data.osmtracks.TracksApi
-import de.westnordost.streetcomplete.data.osmtracks.TracksApiImpl
-import de.westnordost.streetcomplete.data.preferences.Preferences
+import de.westnordost.streetcomplete.data.osm.edits.upload.changesets.ChangesetApiClient
+import de.westnordost.streetcomplete.data.osm.edits.upload.changesets.ChangesetApiSerializer
+import de.westnordost.streetcomplete.data.user.UserApiClient
+import de.westnordost.streetcomplete.data.osm.mapdata.MapDataApiClient
+import de.westnordost.streetcomplete.data.osm.mapdata.MapDataApiParser
+import de.westnordost.streetcomplete.data.osm.mapdata.MapDataApiSerializer
+import de.westnordost.streetcomplete.data.osmnotes.NotesApiClient
+import de.westnordost.streetcomplete.data.osmnotes.NotesApiParser
+import de.westnordost.streetcomplete.data.osmtracks.TracksApiClient
+import de.westnordost.streetcomplete.data.osmtracks.TracksSerializer
+import de.westnordost.streetcomplete.data.user.UserApiParser
 import org.koin.androidx.workmanager.dsl.worker
 import org.koin.core.qualifier.named
 import org.koin.dsl.module
@@ -19,17 +20,21 @@ private const val OSM_API_URL = "https://api.openstreetmap.org/api/0.6/"
 val osmApiModule = module {
     factory { Cleaner(get(), get(), get(), get(), get(), get()) }
     factory { CacheTrimmer(get(), get()) }
-    factory<MapDataApi> { MapDataApiImpl(get()) }
-    factory<NotesApi> { NotesApiImpl(get()) }
-    factory<TracksApi> { TracksApiImpl(get()) }
+    factory { MapDataApiClient(get(), OSM_API_URL, get(), get(), get()) }
+    factory { NotesApiClient(get(), OSM_API_URL, get(), get()) }
+    factory { TracksApiClient(get(), OSM_API_URL, get(), get()) }
+    factory { UserApiClient(get(), OSM_API_URL, get(), get()) }
+    factory { ChangesetApiClient(get(), OSM_API_URL, get(), get()) }
+
     factory { Preloader(get(named("CountryBoundariesLazy")), get(named("FeatureDictionaryLazy"))) }
-    factory { UserApi(get()) }
 
-    single { OsmConnection(
-        OSM_API_URL,
-        ApplicationConstants.USER_AGENT,
-        get<Preferences>().oAuth2AccessToken
-    ) }
+    factory { UserApiParser() }
+    factory { NotesApiParser() }
+    factory { TracksSerializer() }
+    factory { MapDataApiParser() }
+    factory { MapDataApiSerializer() }
+    factory { ChangesetApiSerializer() }
+
     single { UnsyncedChangesCountSource(get(), get()) }
 
     worker { CleanerWorker(get(), get(), get()) }
diff --git a/app/src/main/java/de/westnordost/streetcomplete/data/download/QueryTooBigException.kt b/app/src/main/java/de/westnordost/streetcomplete/data/download/QueryTooBigException.kt
deleted file mode 100644
index ff441f7187f..00000000000
--- a/app/src/main/java/de/westnordost/streetcomplete/data/download/QueryTooBigException.kt
+++ /dev/null
@@ -1,6 +0,0 @@
-package de.westnordost.streetcomplete.data.download
-
-class QueryTooBigException @JvmOverloads constructor(
-    message: String? = null,
-    cause: Throwable? = null
-) : RuntimeException(message, cause)
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 60f22c7b617..184e6118c18 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
@@ -5,14 +5,15 @@ 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.MapDataApi
+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.data.osm.mapdata.RemoteMapDataRepository
 
 class ElementEditUploader(
     private val changesetManager: OpenChangesetsManager,
-    private val mapDataApi: MapDataApi,
+    private val mapDataApi: MapDataApiClient,
     private val mapDataController: MapDataController
 ) {
 
@@ -20,8 +21,8 @@ class ElementEditUploader(
      *
      *  @throws ConflictException if element has been changed server-side in an incompatible way
      */
-    fun upload(edit: ElementEdit, getIdProvider: () -> ElementIdProvider): MapDataUpdates {
-        val remoteChanges by lazy { edit.action.createUpdates(mapDataApi, getIdProvider()) }
+    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
@@ -48,13 +49,16 @@ class ElementEditUploader(
         }
     }
 
-    private fun uploadChanges(edit: ElementEdit, mapDataChanges: MapDataChanges, newChangeset: Boolean): MapDataUpdates {
-        val changesetId =
-            if (newChangeset) {
-                changesetManager.createChangeset(edit.type, edit.source, edit.position)
-            } else {
-                changesetManager.getOrCreateChangeset(edit.type, edit.source, edit.position, edit.isNearUserLocation)
-            }
-        return mapDataApi.uploadChanges(changesetId, mapDataChanges, ApplicationConstants.IGNORED_RELATION_TYPES)
+    private suspend fun uploadChanges(
+        edit: ElementEdit,
+        changes: MapDataChanges,
+        newChangeset: Boolean
+    ): MapDataUpdates {
+        val changesetId = if (newChangeset) {
+            changesetManager.createChangeset(edit.type, edit.source, edit.position)
+        } else {
+            changesetManager.getOrCreateChangeset(edit.type, edit.source, edit.position, edit.isNearUserLocation)
+        }
+        return mapDataApi.uploadChanges(changesetId, changes, ApplicationConstants.IGNORED_RELATION_TYPES)
     }
 }
diff --git a/app/src/main/java/de/westnordost/streetcomplete/data/osm/edits/upload/ElementEditsUploader.kt b/app/src/main/java/de/westnordost/streetcomplete/data/osm/edits/upload/ElementEditsUploader.kt
index c4e2b3d4880..98335d76ee8 100644
--- a/app/src/main/java/de/westnordost/streetcomplete/data/osm/edits/upload/ElementEditsUploader.kt
+++ b/app/src/main/java/de/westnordost/streetcomplete/data/osm/edits/upload/ElementEditsUploader.kt
@@ -9,7 +9,7 @@ import de.westnordost.streetcomplete.data.osm.mapdata.Element
 import de.westnordost.streetcomplete.data.osm.mapdata.ElementKey
 import de.westnordost.streetcomplete.data.osm.mapdata.ElementType
 import de.westnordost.streetcomplete.data.osm.mapdata.MapData
-import de.westnordost.streetcomplete.data.osm.mapdata.MapDataApi
+import de.westnordost.streetcomplete.data.osm.mapdata.MapDataApiClient
 import de.westnordost.streetcomplete.data.osm.mapdata.MapDataController
 import de.westnordost.streetcomplete.data.osm.mapdata.MapDataUpdates
 import de.westnordost.streetcomplete.data.osm.mapdata.MutableMapData
@@ -30,7 +30,7 @@ class ElementEditsUploader(
     private val noteEditsController: NoteEditsController,
     private val mapDataController: MapDataController,
     private val singleUploader: ElementEditUploader,
-    private val mapDataApi: MapDataApi,
+    private val mapDataApi: MapDataApiClient,
     private val statisticsController: StatisticsController
 ) {
     var uploadedChangeListener: OnUploadedChangeListener? = null
@@ -95,12 +95,10 @@ class ElementEditsUploader(
     }
 
     private suspend fun fetchElementComplete(elementType: ElementType, elementId: Long): MapData? =
-        withContext(Dispatchers.IO) {
-            when (elementType) {
-                ElementType.NODE -> mapDataApi.getNode(elementId)?.let { MutableMapData(listOf(it)) }
-                ElementType.WAY -> mapDataApi.getWayComplete(elementId)
-                ElementType.RELATION -> mapDataApi.getRelationComplete(elementId)
-            }
+        when (elementType) {
+            ElementType.NODE -> mapDataApi.getNode(elementId)?.let { MutableMapData(listOf(it)) }
+            ElementType.WAY -> mapDataApi.getWayComplete(elementId)
+            ElementType.RELATION -> mapDataApi.getRelationComplete(elementId)
         }
 
     companion object {
diff --git a/app/src/main/java/de/westnordost/streetcomplete/data/osm/edits/upload/changesets/ChangesetApiClient.kt b/app/src/main/java/de/westnordost/streetcomplete/data/osm/edits/upload/changesets/ChangesetApiClient.kt
new file mode 100644
index 00000000000..a4e4c26bb85
--- /dev/null
+++ b/app/src/main/java/de/westnordost/streetcomplete/data/osm/edits/upload/changesets/ChangesetApiClient.kt
@@ -0,0 +1,68 @@
+package de.westnordost.streetcomplete.data.osm.edits.upload.changesets
+
+import de.westnordost.streetcomplete.data.AuthorizationException
+import de.westnordost.streetcomplete.data.ConflictException
+import de.westnordost.streetcomplete.data.ConnectionException
+import de.westnordost.streetcomplete.data.user.UserLoginSource
+import de.westnordost.streetcomplete.data.wrapApiClientExceptions
+import io.ktor.client.HttpClient
+import io.ktor.client.call.body
+import io.ktor.client.plugins.ClientRequestException
+import io.ktor.client.plugins.expectSuccess
+import io.ktor.client.request.bearerAuth
+import io.ktor.client.request.put
+import io.ktor.client.request.setBody
+import io.ktor.http.HttpStatusCode
+
+class ChangesetApiClient(
+    private val httpClient: HttpClient,
+    private val baseUrl: String,
+    private val userLoginSource: UserLoginSource,
+    private val serializer: ChangesetApiSerializer,
+) {
+    /**
+     * Open a new changeset with the given tags
+     *
+     * @param tags tags of this changeset. Usually it is comment and source.
+     *
+     * @throws AuthorizationException if the application does not have permission to edit the map
+     *                                (OAuth scope "write_api")
+     * @throws ConnectionException if a temporary network connection problem occurs
+     *
+     * @return the id of the changeset
+     */
+    suspend fun open(tags: Map<String, String>): Long = wrapApiClientExceptions {
+        val response = httpClient.put(baseUrl + "changeset/create") {
+            userLoginSource.accessToken?.let { bearerAuth(it) }
+            setBody(serializer.serialize(tags))
+            expectSuccess = true
+        }
+        return response.body<String>().toLong()
+    }
+
+    /**
+     * Closes the given changeset.
+     *
+     * @param id id of the changeset to close
+     *
+     * @throws ConflictException if the changeset has already been closed or does not exist
+     * @throws AuthorizationException if the application does not have permission to edit the map
+     *                                (OAuth scope "write_api")
+     * @throws ConnectionException if a temporary network connection problem occurs
+     */
+    suspend fun close(id: Long): Unit = wrapApiClientExceptions {
+        try {
+            httpClient.put(baseUrl + "changeset/$id/close") {
+                userLoginSource.accessToken?.let { bearerAuth(it) }
+                expectSuccess = true
+            }
+        } catch (e: ClientRequestException) {
+            when (e.response.status) {
+                HttpStatusCode.Conflict, HttpStatusCode.NotFound -> {
+                    throw ConflictException(e.message, e)
+                }
+                else -> throw e
+            }
+        }
+    }
+}
diff --git a/app/src/main/java/de/westnordost/streetcomplete/data/osm/edits/upload/changesets/ChangesetApiSerializer.kt b/app/src/main/java/de/westnordost/streetcomplete/data/osm/edits/upload/changesets/ChangesetApiSerializer.kt
new file mode 100644
index 00000000000..0906973deea
--- /dev/null
+++ b/app/src/main/java/de/westnordost/streetcomplete/data/osm/edits/upload/changesets/ChangesetApiSerializer.kt
@@ -0,0 +1,29 @@
+package de.westnordost.streetcomplete.data.osm.edits.upload.changesets
+
+import de.westnordost.streetcomplete.util.ktx.attribute
+import de.westnordost.streetcomplete.util.ktx.endTag
+import de.westnordost.streetcomplete.util.ktx.startTag
+import nl.adaptivity.xmlutil.XmlWriter
+import nl.adaptivity.xmlutil.newWriter
+import nl.adaptivity.xmlutil.xmlStreaming
+
+class ChangesetApiSerializer {
+    fun serialize(changesetTags: Map<String, String>): String {
+        val buffer = StringBuilder()
+        xmlStreaming.newWriter(buffer).serializeChangeset(changesetTags)
+        return buffer.toString()
+    }
+}
+
+private fun XmlWriter.serializeChangeset(changesetTags: Map<String, String>) {
+    startTag("osm")
+    startTag("changeset")
+    for ((k, v) in changesetTags) {
+        startTag("tag")
+        attribute("k", k)
+        attribute("v", v)
+        endTag("tag")
+    }
+    endTag("changeset")
+    endTag("osm")
+}
diff --git a/app/src/main/java/de/westnordost/streetcomplete/data/osm/edits/upload/changesets/OpenChangesetsManager.kt b/app/src/main/java/de/westnordost/streetcomplete/data/osm/edits/upload/changesets/OpenChangesetsManager.kt
index 82aad39e8ca..1fdd85df85b 100644
--- a/app/src/main/java/de/westnordost/streetcomplete/data/osm/edits/upload/changesets/OpenChangesetsManager.kt
+++ b/app/src/main/java/de/westnordost/streetcomplete/data/osm/edits/upload/changesets/OpenChangesetsManager.kt
@@ -5,27 +5,28 @@ import de.westnordost.streetcomplete.ApplicationConstants.USER_AGENT
 import de.westnordost.streetcomplete.data.ConflictException
 import de.westnordost.streetcomplete.data.osm.edits.ElementEditType
 import de.westnordost.streetcomplete.data.osm.mapdata.LatLon
-import de.westnordost.streetcomplete.data.osm.mapdata.MapDataApi
 import de.westnordost.streetcomplete.data.preferences.Preferences
 import de.westnordost.streetcomplete.util.ktx.nowAsEpochMilliseconds
 import de.westnordost.streetcomplete.util.logs.Log
 import de.westnordost.streetcomplete.util.math.distanceTo
+import kotlinx.coroutines.Dispatchers.IO
+import kotlinx.coroutines.withContext
 import java.util.Locale
 
 /** Manages the creation and reusage of changesets */
 class OpenChangesetsManager(
-    private val mapDataApi: MapDataApi,
+    private val changesetApiClient: ChangesetApiClient,
     private val openChangesetsDB: OpenChangesetsDao,
     private val changesetAutoCloser: ChangesetAutoCloser,
     private val prefs: Preferences
 ) {
-    fun getOrCreateChangeset(
+    suspend fun getOrCreateChangeset(
         type: ElementEditType,
         source: String,
         position: LatLon,
         createNewIfTooFarAway: Boolean
-    ): Long = synchronized(this) {
-        val openChangeset = openChangesetsDB.get(type.name, source)
+    ): Long {
+        val openChangeset = withContext(IO) { openChangesetsDB.get(type.name, source) }
             ?: return createChangeset(type, source, position)
 
         if (createNewIfTooFarAway && position.distanceTo(openChangeset.lastPosition) > MAX_LAST_EDIT_DISTANCE) {
@@ -36,35 +37,30 @@ class OpenChangesetsManager(
         }
     }
 
-    fun createChangeset(
-        type: ElementEditType,
-        source: String,
-        position: LatLon
-    ): Long = synchronized(this) {
-        val changesetId = mapDataApi.openChangeset(createChangesetTags(type, source))
-        openChangesetsDB.put(OpenChangeset(type.name, source, changesetId, position))
+    suspend fun createChangeset(type: ElementEditType, source: String, position: LatLon): Long {
+        val changesetId = changesetApiClient.open(createChangesetTags(type, source))
+        withContext(IO) { openChangesetsDB.put(OpenChangeset(type.name, source, changesetId, position)) }
         changesetAutoCloser.enqueue(CLOSE_CHANGESETS_AFTER_INACTIVITY_OF)
         Log.i(TAG, "Created changeset #$changesetId")
         return changesetId
     }
 
-    fun closeOldChangesets() = synchronized(this) {
+    suspend fun closeOldChangesets() {
         val timePassed = nowAsEpochMilliseconds() - prefs.lastEditTime
         if (timePassed < CLOSE_CHANGESETS_AFTER_INACTIVITY_OF) return
 
-        for (info in openChangesetsDB.getAll()) {
-            closeChangeset(info)
-        }
+        val openChangesets = withContext(IO) { openChangesetsDB.getAll() }
+        openChangesets.forEach { closeChangeset(it) }
     }
 
-    private fun closeChangeset(openChangeset: OpenChangeset) {
+    private suspend fun closeChangeset(openChangeset: OpenChangeset) {
         try {
-            mapDataApi.closeChangeset(openChangeset.changesetId)
+            changesetApiClient.close(openChangeset.changesetId)
             Log.i(TAG, "Closed changeset #${openChangeset.changesetId}")
         } catch (e: ConflictException) {
             Log.w(TAG, "Couldn't close changeset #${openChangeset.changesetId} because it has already been closed")
         } finally {
-            openChangesetsDB.delete(openChangeset.questType, openChangeset.source)
+            withContext(IO) { openChangesetsDB.delete(openChangeset.questType, openChangeset.source) }
         }
     }
 
diff --git a/app/src/main/java/de/westnordost/streetcomplete/data/osm/mapdata/BoundingBox.kt b/app/src/main/java/de/westnordost/streetcomplete/data/osm/mapdata/BoundingBox.kt
index 866eff5a5ec..65d69257de4 100644
--- a/app/src/main/java/de/westnordost/streetcomplete/data/osm/mapdata/BoundingBox.kt
+++ b/app/src/main/java/de/westnordost/streetcomplete/data/osm/mapdata/BoundingBox.kt
@@ -1,5 +1,6 @@
 package de.westnordost.streetcomplete.data.osm.mapdata
 
+import de.westnordost.streetcomplete.util.ktx.format
 import de.westnordost.streetcomplete.util.math.normalizeLongitude
 import kotlinx.serialization.Serializable
 
@@ -47,3 +48,11 @@ fun BoundingBox.toPolygon() = listOf(
     LatLon(max.latitude, min.longitude),
     min,
 )
+
+/** bounding box bounds in counter-clockwise direction, starting with min longitude */
+fun BoundingBox.toOsmApiString(): String = listOf(
+    min.longitude,
+    min.latitude,
+    max.longitude,
+    max.latitude
+).joinToString(",") { it.format(7) }
diff --git a/app/src/main/java/de/westnordost/streetcomplete/data/osm/mapdata/Element.kt b/app/src/main/java/de/westnordost/streetcomplete/data/osm/mapdata/Element.kt
index 98466d233d9..a6baeb95d56 100644
--- a/app/src/main/java/de/westnordost/streetcomplete/data/osm/mapdata/Element.kt
+++ b/app/src/main/java/de/westnordost/streetcomplete/data/osm/mapdata/Element.kt
@@ -17,7 +17,7 @@ sealed class Element {
 data class Node(
     override val id: Long,
     val position: LatLon,
-    override val tags: Map<String, String> = HashMap(0),
+    override val tags: Map<String, String> = emptyMap(),
     override val version: Int = 1,
     override val timestampEdited: Long = 0
 ) : Element() {
@@ -30,7 +30,7 @@ data class Node(
 data class Way(
     override val id: Long,
     val nodeIds: List<Long>,
-    override val tags: Map<String, String> = HashMap(0),
+    override val tags: Map<String, String> = emptyMap(),
     override val version: Int = 1,
     override val timestampEdited: Long = 0
 ) : Element() {
@@ -45,7 +45,7 @@ data class Way(
 data class Relation(
     override val id: Long,
     val members: List<RelationMember>,
-    override val tags: Map<String, String> = HashMap(0),
+    override val tags: Map<String, String> = emptyMap(),
     override val version: Int = 1,
     override val timestampEdited: Long = 0
 ) : Element() {
@@ -63,13 +63,6 @@ data class RelationMember(
 
 enum class ElementType { NODE, WAY, RELATION }
 
-data class DiffElement(
-    val type: ElementType,
-    val clientId: Long,
-    val serverId: Long? = null,
-    val serverVersion: Int? = null
-)
-
 @Serializable
 data class LatLon(
     @SerialName("lat")
diff --git a/app/src/main/java/de/westnordost/streetcomplete/data/osm/mapdata/MapDataApi.kt b/app/src/main/java/de/westnordost/streetcomplete/data/osm/mapdata/MapDataApi.kt
deleted file mode 100644
index b76dc360d08..00000000000
--- a/app/src/main/java/de/westnordost/streetcomplete/data/osm/mapdata/MapDataApi.kt
+++ /dev/null
@@ -1,192 +0,0 @@
-package de.westnordost.streetcomplete.data.osm.mapdata
-
-import de.westnordost.streetcomplete.data.AuthorizationException
-import de.westnordost.streetcomplete.data.ConflictException
-import de.westnordost.streetcomplete.data.ConnectionException
-import de.westnordost.streetcomplete.data.download.QueryTooBigException
-
-/** Get and upload changes to map data */
-interface MapDataApi : MapDataRepository {
-
-    /**
-     * Upload changes into an opened changeset.
-     *
-     * @param changesetId id of the changeset to upload changes into
-     * @param changes changes to upload.
-     *
-     * @throws ConflictException if the changeset has already been closed, there is a conflict for
-     *                           the elements being uploaded or the user who created the changeset
-     *                           is not the same as the one uploading the change
-     * @throws AuthorizationException if the application does not have permission to edit the map
-     *                                (Permission.MODIFY_MAP)
-     * @throws ConnectionException if a temporary network connection problem occurs
-     *
-     * @return the updated elements
-     */
-    fun uploadChanges(changesetId: Long, changes: MapDataChanges, ignoreRelationTypes: Set<String?> = emptySet()): MapDataUpdates
-
-    /**
-     * Open a new changeset with the given tags
-     *
-     * @param tags tags of this changeset. Usually it is comment and source.
-     *
-     * @throws AuthorizationException if the application does not have permission to edit the map
-     *                                (Permission.MODIFY_MAP)
-     * @throws ConnectionException if a temporary network connection problem occurs
-     *
-     * @return the id of the changeset
-     */
-    fun openChangeset(tags: Map<String, String?>): Long
-
-    /**
-     * Closes the given changeset.
-     *
-     * @param changesetId id of the changeset to close
-     *
-     * @throws ConflictException if the changeset has already been closed
-     * @throws AuthorizationException if the application does not have permission to edit the map
-     *                                (Permission.MODIFY_MAP)
-     * @throws ConnectionException if a temporary network connection problem occurs
-     */
-    fun closeChangeset(changesetId: Long)
-
-    /**
-     * Feeds map data to the given MapDataHandler.
-     * If not logged in, the Changeset for each returned element will be null
-     *
-     * @param bounds rectangle in which to query map data. May not cross the 180th meridian. This is
-     * usually limited at 0.25 square degrees. Check the server capabilities.
-     * @param mutableMapData mutable map data to add the add the data to
-     * @param ignoreRelationTypes don't put any relations of the given types in the given mutableMapData
-     *
-     * @throws QueryTooBigException if the bounds area is too large
-     * @throws IllegalArgumentException if the bounds cross the 180th meridian.
-     * @throws ConnectionException if a temporary network connection problem occurs
-     *
-     * @return the map data
-     */
-    fun getMap(bounds: BoundingBox, mutableMapData: MutableMapData, ignoreRelationTypes: Set<String?> = emptySet())
-
-    /**
-     * Queries the way with the given id plus all nodes that are in referenced by it.
-     * If not logged in, the Changeset for each returned element will be null
-     *
-     * @param id the way's id
-     *
-     * @throws ConnectionException if a temporary network connection problem occurs
-     *
-     * @return the map data
-     */
-    override fun getWayComplete(id: Long): MapData?
-
-    /**
-     * Queries the relation with the given id plus all it's members and all nodes of ways that are
-     * members of the relation.
-     * If not logged in, the Changeset for each returned element will be null
-     *
-     * @param id the relation's id
-     *
-     * @throws ConnectionException if a temporary network connection problem occurs
-     *
-     * @return the map data
-     */
-    override fun getRelationComplete(id: Long): MapData?
-
-    /**
-     * Note that if not logged in, the Changeset for each returned element will be null
-     *
-     * @param id the node's id
-     *
-     * @throws ConnectionException if a temporary network connection problem occurs
-     *
-     * @return the node with the given id or null if it does not exist
-     */
-    override fun getNode(id: Long): Node?
-
-    /**
-     * Note that if not logged in, the Changeset for each returned element will be null
-     *
-     * @param id the way's id
-     *
-     * @throws ConnectionException if a temporary network connection problem occurs
-     *
-     * @return the way with the given id or null if it does not exist
-     */
-    override fun getWay(id: Long): Way?
-
-    /**
-     * Note that if not logged in, the Changeset for each returned element will be null
-     *
-     * @param id the relation's id
-     *
-     * @throws ConnectionException if a temporary network connection problem occurs
-     *
-     * @return the relation with the given id or null if it does not exist
-     */
-    override fun getRelation(id: Long): Relation?
-
-    /**
-     * Note that if not logged in, the Changeset for each returned element will be null
-     *
-     * @param id the node's id
-     *
-     * @throws ConnectionException if a temporary network connection problem occurs
-     *
-     * @return all ways that reference the node with the given id. Empty if none.
-     */
-    override fun getWaysForNode(id: Long): List<Way>
-
-    /**
-     * Note that if not logged in, the Changeset for each returned element will be null
-     *
-     * @param id the node's id
-     *
-     * @throws ConnectionException if a temporary network connection problem occurs
-     *
-     * @return all relations that reference the node with the given id. Empty if none.
-     */
-    override fun getRelationsForNode(id: Long): List<Relation>
-
-    /**
-     * Note that if not logged in, the Changeset for each returned element will be null
-     *
-     * @param id the way's id
-     *
-     * @throws ConnectionException if a temporary network connection problem occurs
-     *
-     * @return all relations that reference the way with the given id. Empty if none.
-     */
-    override fun getRelationsForWay(id: Long): List<Relation>
-
-    /**
-     * Note that if not logged in, the Changeset for each returned element will be null
-     *
-     * @param id the relation's id
-     *
-     * @throws ConnectionException if a temporary network connection problem occurs
-     *
-     * @return all relations that reference the relation with the given id. Empty if none.
-     */
-    override fun getRelationsForRelation(id: Long): List<Relation>
-}
-
-/** Data class that contains the map data updates (updated elements, deleted elements, elements
- *  whose id have been updated) after the modifications have been uploaded */
-data class MapDataUpdates(
-    val updated: Collection<Element> = emptyList(),
-    val deleted: Collection<ElementKey> = emptyList(),
-    val idUpdates: Collection<ElementIdUpdate> = emptyList()
-)
-
-data class ElementIdUpdate(
-    val elementType: ElementType,
-    val oldElementId: Long,
-    val newElementId: Long
-)
-
-/** Data class that contains a the request to create, modify elements and delete the given elements */
-data class MapDataChanges(
-    val creations: Collection<Element> = emptyList(),
-    val modifications: Collection<Element> = emptyList(),
-    val deletions: Collection<Element> = emptyList()
-)
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
new file mode 100644
index 00000000000..677a6e93040
--- /dev/null
+++ b/app/src/main/java/de/westnordost/streetcomplete/data/osm/mapdata/MapDataApiClient.kt
@@ -0,0 +1,209 @@
+package de.westnordost.streetcomplete.data.osm.mapdata
+
+import de.westnordost.streetcomplete.data.AuthorizationException
+import de.westnordost.streetcomplete.data.ConflictException
+import de.westnordost.streetcomplete.data.ConnectionException
+import de.westnordost.streetcomplete.data.QueryTooBigException
+import de.westnordost.streetcomplete.data.user.UserLoginSource
+import de.westnordost.streetcomplete.data.wrapApiClientExceptions
+import io.ktor.client.HttpClient
+import io.ktor.client.call.body
+import io.ktor.client.plugins.ClientRequestException
+import io.ktor.client.plugins.expectSuccess
+import io.ktor.client.request.bearerAuth
+import io.ktor.client.request.get
+import io.ktor.client.request.parameter
+import io.ktor.client.request.post
+import io.ktor.client.request.setBody
+import io.ktor.http.HttpStatusCode
+
+/** Get and upload changes to map data */
+class MapDataApiClient(
+    private val httpClient: HttpClient,
+    private val baseUrl: String,
+    private val userLoginSource: UserLoginSource,
+    private val parser: MapDataApiParser,
+    private val serializer: MapDataApiSerializer,
+) {
+
+    /**
+     * Upload changes into an opened changeset.
+     *
+     * @param changesetId id of the changeset to upload changes into
+     * @param changes changes to upload.
+     * @param ignoreRelationTypes omit any updates to relations of the given types from the result.
+     *                            Such relations can still be referred to as relation members,
+     *                            though, the relations themselves are just not included
+     *
+     * @throws ConflictException if the changeset has already been closed, there is a conflict for
+     *                           the elements being uploaded or the user who created the changeset
+     *                           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 ConnectionException if a temporary network connection problem occurs
+     *
+     * @return the updated elements
+     */
+    suspend fun uploadChanges(
+        changesetId: Long,
+        changes: MapDataChanges,
+        ignoreRelationTypes: Set<String?> = emptySet()
+    ): MapDataUpdates = wrapApiClientExceptions {
+        try {
+            val response = httpClient.post(baseUrl + "changeset/$changesetId/upload") {
+                userLoginSource.accessToken?.let { bearerAuth(it) }
+                setBody(serializer.serializeMapDataChanges(changes, changesetId))
+                expectSuccess = true
+            }
+            val updates = parser.parseElementUpdates(response.body<String>())
+            val changedElements = changes.creations + changes.modifications + changes.deletions
+            return createMapDataUpdates(changedElements, updates, ignoreRelationTypes)
+        } catch (e: ClientRequestException) {
+            when (e.response.status) {
+                // current element version is outdated, current changeset has been closed already
+                HttpStatusCode.Conflict,
+                // an element referred to by another element does not exist (anymore) or was redacted
+                HttpStatusCode.PreconditionFailed,
+                // some elements do not exist (anymore)
+                HttpStatusCode.NotFound -> {
+                    throw ConflictException(e.message, e)
+                }
+                else -> throw e
+            }
+        }
+    }
+
+    /**
+     * Returns the map data in the given bounding box.
+     *
+     * @param bounds rectangle in which to query map data. May not cross the 180th meridian. This is
+     * usually limited at 0.25 square degrees. Check the server capabilities.
+     * @param ignoreRelationTypes omit any relations of the given types from the result.
+     *                            Such relations can still be referred to as relation members,
+     *                            though, the relations themselves are just not included
+     *
+     * @throws QueryTooBigException if the bounds area is too large or too many elements would be returned
+     * @throws IllegalArgumentException if the bounds cross the 180th meridian.
+     * @throws ConnectionException if a temporary network connection problem occurs
+     *
+     * @return the map data
+     */
+    suspend fun getMap(
+        bounds: BoundingBox,
+        ignoreRelationTypes: Set<String?> = emptySet()
+    ): MutableMapData = wrapApiClientExceptions {
+        if (bounds.crosses180thMeridian) {
+            throw IllegalArgumentException("Bounding box crosses 180th meridian")
+        }
+
+        try {
+            val response = httpClient.get(baseUrl + "map") {
+                parameter("bbox", bounds.toOsmApiString())
+                expectSuccess = true
+            }
+            return parser.parseMapData(response.body(), ignoreRelationTypes)
+        } catch (e: ClientRequestException) {
+            if (e.response.status == HttpStatusCode.BadRequest) {
+                throw QueryTooBigException(e.message, e)
+            } else {
+                throw e
+            }
+        }
+    }
+
+    /**
+     * Returns the given way by id plus all its nodes or null if the way does not exist.
+     *
+     * @throws ConnectionException if a temporary network connection problem occurs
+     */
+    suspend fun getWayComplete(id: Long): MapData? =
+        getMapDataOrNull("way/$id/full")
+
+    /**
+     * Returns the given relation by id plus all its members and all nodes of ways that are members
+     * of the relation. Or null if the relation does not exist.
+     *
+     * @throws ConnectionException if a temporary network connection problem occurs
+     */
+    suspend fun getRelationComplete(id: Long): MapData? =
+        getMapDataOrNull("relation/$id/full")
+
+    /**
+     * Return the given node by id or null if it doesn't exist
+     *
+     * @throws ConnectionException if a temporary network connection problem occurs
+     */
+    suspend fun getNode(id: Long): Node? =
+        getMapDataOrNull("node/$id")?.nodes?.single()
+
+    /**
+     * Return the given way by id or null if it doesn't exist
+     *
+     * @throws ConnectionException if a temporary network connection problem occurs
+     */
+    suspend fun getWay(id: Long): Way? =
+        getMapDataOrNull("way/$id")?.ways?.single()
+
+    /**
+     * Return the given relation by id or null if it doesn't exist
+     *
+     * @throws ConnectionException if a temporary network connection problem occurs
+     */
+    suspend fun getRelation(id: Long): Relation? =
+        getMapDataOrNull("relation/$id")?.relations?.single()
+
+    /**
+     * Return all ways in which the given node is used.
+     *
+     * @throws ConnectionException if a temporary network connection problem occurs
+     */
+    suspend fun getWaysForNode(id: Long): Collection<Way> =
+        getMapDataOrNull("node/$id/ways")?.ways.orEmpty()
+
+    /**
+     * Return all relations in which the given node is used.
+     *
+     * @throws ConnectionException if a temporary network connection problem occurs
+     */
+    suspend fun getRelationsForNode(id: Long): Collection<Relation> =
+        getMapDataOrNull("node/$id/relations")?.relations.orEmpty()
+
+    /**
+     * Return all relations in which the given way is used.
+     *
+     * @throws ConnectionException if a temporary network connection problem occurs
+     */
+    suspend fun getRelationsForWay(id: Long): Collection<Relation> =
+        getMapDataOrNull("way/$id/relations")?.relations.orEmpty()
+
+    /**
+     * Return all relations in which the given relation is used.
+     *
+     * @throws ConnectionException if a temporary network connection problem occurs
+     */
+    suspend fun getRelationsForRelation(id: Long): Collection<Relation> =
+        getMapDataOrNull("relation/$id/relations")?.relations.orEmpty()
+
+    private suspend fun getMapDataOrNull(query: String): MapData? = wrapApiClientExceptions {
+        try {
+            val response = httpClient.get(baseUrl + query) { expectSuccess = true }
+            return parser.parseMapData(response.body(), emptySet())
+        } catch (e: ClientRequestException) {
+            when (e.response.status) {
+                HttpStatusCode.Gone, HttpStatusCode.NotFound -> return null
+                else -> throw e
+            }
+        }
+    }
+}
+
+/** Data class that contains the request to create, modify elements and delete the given elements */
+data class MapDataChanges(
+    val creations: Collection<Element> = emptyList(),
+    val modifications: Collection<Element> = emptyList(),
+    val deletions: Collection<Element> = emptyList()
+)
+
+sealed interface ElementUpdateAction
+data class UpdateElement(val newId: Long, val newVersion: Int) : ElementUpdateAction
+data object DeleteElement : ElementUpdateAction
diff --git a/app/src/main/java/de/westnordost/streetcomplete/data/osm/mapdata/MapDataApiImpl.kt b/app/src/main/java/de/westnordost/streetcomplete/data/osm/mapdata/MapDataApiImpl.kt
deleted file mode 100644
index fc166142401..00000000000
--- a/app/src/main/java/de/westnordost/streetcomplete/data/osm/mapdata/MapDataApiImpl.kt
+++ /dev/null
@@ -1,257 +0,0 @@
-package de.westnordost.streetcomplete.data.osm.mapdata
-
-import de.westnordost.osmapi.OsmConnection
-import de.westnordost.osmapi.common.errors.OsmApiException
-import de.westnordost.osmapi.common.errors.OsmApiReadResponseException
-import de.westnordost.osmapi.common.errors.OsmAuthorizationException
-import de.westnordost.osmapi.common.errors.OsmConflictException
-import de.westnordost.osmapi.common.errors.OsmConnectionException
-import de.westnordost.osmapi.common.errors.OsmNotFoundException
-import de.westnordost.osmapi.common.errors.OsmQueryTooBigException
-import de.westnordost.osmapi.map.data.OsmElement
-import de.westnordost.osmapi.map.data.OsmLatLon
-import de.westnordost.osmapi.map.data.OsmNode
-import de.westnordost.osmapi.map.data.OsmRelation
-import de.westnordost.osmapi.map.data.OsmRelationMember
-import de.westnordost.osmapi.map.data.OsmWay
-import de.westnordost.osmapi.map.handler.MapDataHandler
-import de.westnordost.streetcomplete.data.AuthorizationException
-import de.westnordost.streetcomplete.data.ConflictException
-import de.westnordost.streetcomplete.data.ConnectionException
-import de.westnordost.streetcomplete.data.download.QueryTooBigException
-import kotlinx.datetime.Instant
-import kotlinx.datetime.toJavaInstant
-import de.westnordost.osmapi.map.MapDataApi as OsmApiMapDataApi
-import de.westnordost.osmapi.map.changes.DiffElement as OsmApiDiffElement
-import de.westnordost.osmapi.map.data.BoundingBox as OsmApiBoundingBox
-import de.westnordost.osmapi.map.data.Element as OsmApiElement
-import de.westnordost.osmapi.map.data.Node as OsmApiNode
-import de.westnordost.osmapi.map.data.Relation as OsmApiRelation
-import de.westnordost.osmapi.map.data.RelationMember as OsmApiRelationMember
-import de.westnordost.osmapi.map.data.Way as OsmApiWay
-
-class MapDataApiImpl(osm: OsmConnection) : MapDataApi {
-
-    private val api: OsmApiMapDataApi = OsmApiMapDataApi(osm)
-
-    override fun uploadChanges(
-        changesetId: Long,
-        changes: MapDataChanges,
-        ignoreRelationTypes: Set<String?>
-    ) = wrapExceptions {
-        try {
-            val handler = UpdatedElementsHandler(ignoreRelationTypes)
-            api.uploadChanges(changesetId, changes.toOsmApiElements()) {
-                handler.handle(it.toDiffElement())
-            }
-            val allChangedElements = changes.creations + changes.modifications + changes.deletions
-            handler.getElementUpdates(allChangedElements)
-        } catch (e: OsmApiException) {
-            throw ConflictException(e.message, e)
-        }
-    }
-
-    override fun openChangeset(tags: Map<String, String?>): Long = wrapExceptions {
-        api.openChangeset(tags)
-    }
-
-    override fun closeChangeset(changesetId: Long) =
-        try {
-            wrapExceptions { api.closeChangeset(changesetId) }
-        } catch (e: OsmNotFoundException) {
-            throw ConflictException(e.message, e)
-        }
-
-    override fun getMap(
-        bounds: BoundingBox,
-        mutableMapData: MutableMapData,
-        ignoreRelationTypes: Set<String?>
-    ) = wrapExceptions {
-        api.getMap(
-            bounds.toOsmApiBoundingBox(),
-            MapDataApiHandler(mutableMapData, ignoreRelationTypes)
-        )
-    }
-
-    override fun getWayComplete(id: Long): MapData? =
-        try {
-            val result = MutableMapData()
-            wrapExceptions { api.getWayComplete(id, MapDataApiHandler(result)) }
-            result
-        } catch (e: OsmNotFoundException) {
-            null
-        }
-
-    override fun getRelationComplete(id: Long): MapData? =
-        try {
-            val result = MutableMapData()
-            wrapExceptions { api.getRelationComplete(id, MapDataApiHandler(result)) }
-            result
-        } catch (e: OsmNotFoundException) {
-            null
-        }
-
-    override fun getNode(id: Long): Node? = wrapExceptions {
-        api.getNode(id)?.toNode()
-    }
-
-    override fun getWay(id: Long): Way? = wrapExceptions {
-        api.getWay(id)?.toWay()
-    }
-
-    override fun getRelation(id: Long): Relation? = wrapExceptions {
-        api.getRelation(id)?.toRelation()
-    }
-
-    override fun getWaysForNode(id: Long): List<Way> = wrapExceptions {
-        api.getWaysForNode(id).map { it.toWay() }
-    }
-
-    override fun getRelationsForNode(id: Long): List<Relation> = wrapExceptions {
-        api.getRelationsForNode(id).map { it.toRelation() }
-    }
-
-    override fun getRelationsForWay(id: Long): List<Relation> = wrapExceptions {
-        api.getRelationsForWay(id).map { it.toRelation() }
-    }
-
-    override fun getRelationsForRelation(id: Long): List<Relation> = wrapExceptions {
-        api.getRelationsForRelation(id).map { it.toRelation() }
-    }
-}
-
-private inline fun <T> wrapExceptions(block: () -> T): T =
-    try {
-        block()
-    } catch (e: OsmAuthorizationException) {
-        throw AuthorizationException(e.message, e)
-    } catch (e: OsmConflictException) {
-        throw ConflictException(e.message, e)
-    } catch (e: OsmQueryTooBigException) {
-        throw QueryTooBigException(e.message, e)
-    } catch (e: OsmConnectionException) {
-        throw ConnectionException(e.message, e)
-    } catch (e: OsmApiReadResponseException) {
-        // probably a temporary connection error
-        throw ConnectionException(e.message, e)
-    } catch (e: OsmApiException) {
-        // request timeout is a temporary connection error
-        throw if (e.errorCode == 408) ConnectionException(e.message, e) else e
-    }
-
-/* --------------------------------- Element -> OsmApiElement ----------------------------------- */
-
-private fun MapDataChanges.toOsmApiElements(): List<OsmApiElement> =
-    creations.map { it.toOsmApiElement().apply { isNew = true } } +
-    modifications.map { it.toOsmApiElement().apply { isModified = true } } +
-    deletions.map { it.toOsmApiElement().apply { isDeleted = true } }
-
-private fun Element.toOsmApiElement(): OsmElement = when (this) {
-    is Node -> toOsmApiNode()
-    is Way -> toOsmApiWay()
-    is Relation -> toOsmApiRelation()
-}
-
-private fun Node.toOsmApiNode() = OsmNode(
-    id,
-    version,
-    OsmLatLon(position.latitude, position.longitude),
-    tags,
-    null,
-    Instant.fromEpochMilliseconds(timestampEdited).toJavaInstant()
-)
-
-private fun Way.toOsmApiWay() = OsmWay(
-    id,
-    version,
-    nodeIds,
-    tags,
-    null,
-    Instant.fromEpochMilliseconds(timestampEdited).toJavaInstant()
-)
-
-private fun Relation.toOsmApiRelation() = OsmRelation(
-    id,
-    version,
-    members.map { it.toOsmRelationMember() },
-    tags,
-    null,
-    Instant.fromEpochMilliseconds(timestampEdited).toJavaInstant()
-)
-
-private fun RelationMember.toOsmRelationMember() = OsmRelationMember(
-    ref,
-    role,
-    type.toOsmElementType()
-)
-
-private fun ElementType.toOsmElementType(): OsmApiElement.Type = when (this) {
-    ElementType.NODE        -> OsmApiElement.Type.NODE
-    ElementType.WAY         -> OsmApiElement.Type.WAY
-    ElementType.RELATION    -> OsmApiElement.Type.RELATION
-}
-
-private fun BoundingBox.toOsmApiBoundingBox() =
-    OsmApiBoundingBox(min.latitude, min.longitude, max.latitude, max.longitude)
-
-/* --------------------------------- OsmApiElement -> Element ----------------------------------- */
-
-private fun OsmApiNode.toNode() =
-    Node(id, LatLon(position.latitude, position.longitude), HashMap(tags), version, editedAt.toEpochMilli())
-
-private fun OsmApiWay.toWay() =
-    Way(id, ArrayList(nodeIds), HashMap(tags), version, editedAt.toEpochMilli())
-
-private fun OsmApiRelation.toRelation() = Relation(
-    id,
-    members.map { it.toRelationMember() }.toMutableList(),
-    HashMap(tags),
-    version,
-    editedAt.toEpochMilli()
-)
-
-private fun OsmApiRelationMember.toRelationMember() =
-    RelationMember(type.toElementType(), ref, role)
-
-private fun OsmApiElement.Type.toElementType(): ElementType = when (this) {
-    OsmApiElement.Type.NODE     -> ElementType.NODE
-    OsmApiElement.Type.WAY      -> ElementType.WAY
-    OsmApiElement.Type.RELATION -> ElementType.RELATION
-}
-
-private fun OsmApiDiffElement.toDiffElement() = DiffElement(
-    type.toElementType(),
-    clientId,
-    serverId,
-    serverVersion
-)
-
-private fun OsmApiBoundingBox.toBoundingBox() =
-    BoundingBox(minLatitude, minLongitude, maxLatitude, maxLongitude)
-
-/* ---------------------------------------------------------------------------------------------- */
-
-private class MapDataApiHandler(
-    val data: MutableMapData,
-    val ignoreRelationTypes: Set<String?> = emptySet()
-) : MapDataHandler {
-
-    override fun handle(bounds: OsmApiBoundingBox) {
-        data.boundingBox = bounds.toBoundingBox()
-    }
-
-    override fun handle(node: OsmApiNode) {
-        data.add(node.toNode())
-    }
-
-    override fun handle(way: OsmApiWay) {
-        data.add(way.toWay())
-    }
-
-    override fun handle(relation: OsmApiRelation) {
-        val relationType = relation.tags?.get("type")
-        if (relationType !in ignoreRelationTypes) {
-            data.add(relation.toRelation())
-        }
-    }
-}
diff --git a/app/src/main/java/de/westnordost/streetcomplete/data/osm/mapdata/MapDataApiParser.kt b/app/src/main/java/de/westnordost/streetcomplete/data/osm/mapdata/MapDataApiParser.kt
new file mode 100644
index 00000000000..f1a43c384d0
--- /dev/null
+++ b/app/src/main/java/de/westnordost/streetcomplete/data/osm/mapdata/MapDataApiParser.kt
@@ -0,0 +1,96 @@
+package de.westnordost.streetcomplete.data.osm.mapdata
+
+import de.westnordost.streetcomplete.util.ktx.attribute
+import de.westnordost.streetcomplete.util.ktx.attributeOrNull
+import kotlinx.datetime.Instant
+import kotlinx.serialization.SerializationException
+import nl.adaptivity.xmlutil.EventType.END_ELEMENT
+import nl.adaptivity.xmlutil.EventType.START_ELEMENT
+import nl.adaptivity.xmlutil.XmlReader
+import nl.adaptivity.xmlutil.xmlStreaming
+
+class MapDataApiParser {
+    fun parseMapData(osmXml: String, ignoreRelationTypes: Set<String?>): MutableMapData =
+        xmlStreaming.newReader(osmXml).parseMapData(ignoreRelationTypes)
+
+    fun parseElementUpdates(diffResultXml: String): Map<ElementKey, ElementUpdateAction> =
+        xmlStreaming.newReader(diffResultXml).parseElementUpdates()
+}
+
+private fun XmlReader.parseMapData(ignoreRelationTypes: Set<String?>): MutableMapData = try {
+    val result = MutableMapData()
+    var tags: MutableMap<String, String>? = null
+    var nodes: MutableList<Long> = ArrayList()
+    var members: MutableList<RelationMember> = ArrayList()
+    var id: Long? = null
+    var position: LatLon? = null
+    var version: Int? = null
+    var timestamp: Long? = null
+
+    forEach { when (it) {
+        START_ELEMENT -> when (localName) {
+            "tag" -> {
+                if (tags == null) tags = HashMap()
+                tags!![attribute("k")] = attribute("v")
+            }
+            "nd" -> nodes.add(attribute("ref").toLong())
+            "member" -> members.add(
+                RelationMember(
+                type = ElementType.valueOf(attribute("type").uppercase()),
+                ref = attribute("ref").toLong(),
+                role = attribute("role")
+            )
+            )
+            "bounds" -> result.boundingBox = BoundingBox(
+                minLatitude = attribute("minlat").toDouble(),
+                minLongitude = attribute("minlon").toDouble(),
+                maxLatitude = attribute("maxlat").toDouble(),
+                maxLongitude = attribute("maxlon").toDouble()
+            )
+            "node", "way", "relation" -> {
+                tags = null
+                id = attribute("id").toLong()
+                version = attribute("version").toInt()
+                timestamp = Instant.parse(attribute("timestamp")).toEpochMilliseconds()
+                when (localName) {
+                    "node" -> position = LatLon(attribute("lat").toDouble(), attribute("lon").toDouble())
+                    "way" -> nodes = ArrayList()
+                    "relation" -> members = ArrayList()
+                }
+            }
+        }
+        END_ELEMENT -> when (localName) {
+            "node" -> result.add(Node(id!!, position!!, tags.orEmpty(), version!!, timestamp!!))
+            "way" -> result.add(Way(id!!, nodes, tags.orEmpty(), version!!, timestamp!!))
+            "relation" -> if (tags.orEmpty()["type"] !in ignoreRelationTypes) {
+                result.add(Relation(id!!, members, tags.orEmpty(), version!!, timestamp!!))
+            }
+        }
+        else -> {}
+    } }
+    result
+} catch (e: Exception) { throw SerializationException(e) }
+
+private fun XmlReader.parseElementUpdates(): Map<ElementKey, ElementUpdateAction> = try {
+    val result = HashMap<ElementKey, ElementUpdateAction>()
+    forEach {
+        if (it == START_ELEMENT) {
+            when (localName) {
+                "node", "way", "relation" -> {
+                    val key = ElementKey(
+                        ElementType.valueOf(localName.uppercase()),
+                        attribute("old_id").toLong()
+                    )
+                    val newId = attributeOrNull("new_id")?.toLong()
+                    val newVersion = attributeOrNull("new_version")?.toInt()
+                    val action =
+                        if (newId != null && newVersion != null) UpdateElement(newId, newVersion)
+                        else DeleteElement
+
+                    result[key] = action
+                }
+            }
+        }
+    }
+    result
+} catch (e: Exception) { throw SerializationException(e) }
diff --git a/app/src/main/java/de/westnordost/streetcomplete/data/osm/mapdata/MapDataApiSerializer.kt b/app/src/main/java/de/westnordost/streetcomplete/data/osm/mapdata/MapDataApiSerializer.kt
new file mode 100644
index 00000000000..f0ade4a63f1
--- /dev/null
+++ b/app/src/main/java/de/westnordost/streetcomplete/data/osm/mapdata/MapDataApiSerializer.kt
@@ -0,0 +1,74 @@
+package de.westnordost.streetcomplete.data.osm.mapdata
+
+import de.westnordost.streetcomplete.util.ktx.attribute
+import de.westnordost.streetcomplete.util.ktx.endTag
+import de.westnordost.streetcomplete.util.ktx.startTag
+import kotlinx.datetime.Instant
+import nl.adaptivity.xmlutil.XmlWriter
+import nl.adaptivity.xmlutil.newWriter
+import nl.adaptivity.xmlutil.xmlStreaming
+
+class MapDataApiSerializer {
+    fun serializeMapDataChanges(changes: MapDataChanges, changesetId: Long): String {
+        val buffer = StringBuilder()
+        xmlStreaming.newWriter(buffer).serializeMapDataChanges(changes, changesetId)
+        return buffer.toString()
+    }
+}
+
+private fun XmlWriter.serializeMapDataChanges(changes: MapDataChanges, changesetId: Long) {
+    startTag("osmChange")
+    if (changes.creations.isNotEmpty()) {
+        startTag("create")
+        changes.creations.forEach { serializeElement(it, changesetId) }
+        endTag("create")
+    }
+    if (changes.modifications.isNotEmpty()) {
+        startTag("modify")
+        changes.modifications.forEach { serializeElement(it, changesetId) }
+        endTag("modify")
+    }
+    if (changes.deletions.isNotEmpty()) {
+        startTag("delete")
+        changes.deletions.forEach { serializeElement(it, changesetId) }
+        endTag("delete")
+    }
+    endTag("osmChange")
+}
+
+private fun XmlWriter.serializeElement(element: Element, changesetId: Long) {
+    startTag(element.type.name.lowercase())
+    attribute("id", element.id.toString())
+    attribute("version", element.version.toString())
+    attribute("changeset", changesetId.toString())
+    attribute("timestamp", Instant.fromEpochMilliseconds(element.timestampEdited).toString())
+    when (element) {
+        is Node -> {
+            attribute("lat", element.position.latitude.toString())
+            attribute("lon", element.position.longitude.toString())
+        }
+        is Way -> {
+            for (node in element.nodeIds) {
+                startTag("nd")
+                attribute("ref", node.toString())
+                endTag("nd")
+            }
+        }
+        is Relation -> {
+            for (member in element.members) {
+                startTag("member")
+                attribute("type", member.type.name.lowercase())
+                attribute("ref", member.ref.toString())
+                attribute("role", member.role)
+                endTag("member")
+            }
+        }
+    }
+    for ((k, v) in element.tags) {
+        startTag("tag")
+        attribute("k", k)
+        attribute("v", v)
+        endTag("tag")
+    }
+    endTag(element.type.name.lowercase())
+}
diff --git a/app/src/main/java/de/westnordost/streetcomplete/data/osm/mapdata/MapDataDownloader.kt b/app/src/main/java/de/westnordost/streetcomplete/data/osm/mapdata/MapDataDownloader.kt
index 533f0d659ec..61b70187ba4 100644
--- a/app/src/main/java/de/westnordost/streetcomplete/data/osm/mapdata/MapDataDownloader.kt
+++ b/app/src/main/java/de/westnordost/streetcomplete/data/osm/mapdata/MapDataDownloader.kt
@@ -1,7 +1,7 @@
 package de.westnordost.streetcomplete.data.osm.mapdata
 
 import de.westnordost.streetcomplete.ApplicationConstants
-import de.westnordost.streetcomplete.data.download.QueryTooBigException
+import de.westnordost.streetcomplete.data.QueryTooBigException
 import de.westnordost.streetcomplete.util.ktx.format
 import de.westnordost.streetcomplete.util.ktx.nowAsEpochMilliseconds
 import de.westnordost.streetcomplete.util.logs.Log
@@ -12,18 +12,14 @@ import kotlinx.coroutines.yield
 
 /** Takes care of downloading all note and osm quests */
 class MapDataDownloader(
-    private val mapDataApi: MapDataApi,
+    private val mapDataApi: MapDataApiClient,
     private val mapDataController: MapDataController
 ) {
     suspend fun download(bbox: BoundingBox) = withContext(Dispatchers.IO) {
         val time = nowAsEpochMilliseconds()
 
-        val mapData = MutableMapData()
         val expandedBBox = bbox.enlargedBy(ApplicationConstants.QUEST_FILTER_PADDING)
-        getMapAndHandleTooBigQuery(expandedBBox, mapData)
-        /* The map data might be filled with several bboxes one after another if the download is
-           split up in several, so lets set the bbox back to the bbox of the complete download */
-        mapData.boundingBox = expandedBBox
+        val mapData = getMapAndHandleTooBigQuery(expandedBBox)
 
         val seconds = (nowAsEpochMilliseconds() - time) / 1000.0
         Log.i(TAG, "Downloaded ${mapData.nodes.size} nodes, ${mapData.ways.size} ways and ${mapData.relations.size} relations in ${seconds.format(1)}s")
@@ -33,13 +29,16 @@ class MapDataDownloader(
         mapDataController.putAllForBBox(bbox, mapData)
     }
 
-    private fun getMapAndHandleTooBigQuery(bounds: BoundingBox, mutableMapData: MutableMapData) {
+    private suspend fun getMapAndHandleTooBigQuery(bounds: BoundingBox): MutableMapData {
         try {
-            mapDataApi.getMap(bounds, mutableMapData, ApplicationConstants.IGNORED_RELATION_TYPES)
+            return mapDataApi.getMap(bounds, ApplicationConstants.IGNORED_RELATION_TYPES)
         } catch (e: QueryTooBigException) {
+            val mapData = MutableMapData()
             for (subBounds in bounds.splitIntoFour()) {
-                getMapAndHandleTooBigQuery(subBounds, mutableMapData)
+                mapData.addAll(getMapAndHandleTooBigQuery(subBounds))
             }
+            mapData.boundingBox = bounds
+            return mapData
         }
     }
 
diff --git a/app/src/main/java/de/westnordost/streetcomplete/data/osm/mapdata/MapDataUpdates.kt b/app/src/main/java/de/westnordost/streetcomplete/data/osm/mapdata/MapDataUpdates.kt
new file mode 100644
index 00000000000..5130ed1a2b3
--- /dev/null
+++ b/app/src/main/java/de/westnordost/streetcomplete/data/osm/mapdata/MapDataUpdates.kt
@@ -0,0 +1,94 @@
+package de.westnordost.streetcomplete.data.osm.mapdata
+
+/** Data class that contains the map data updates (updated elements, deleted elements, elements
+ *  whose id have been updated) after the modifications have been uploaded */
+data class MapDataUpdates(
+    val updated: Collection<Element> = emptyList(),
+    val deleted: Collection<ElementKey> = emptyList(),
+    val idUpdates: Collection<ElementIdUpdate> = emptyList()
+)
+
+data class ElementIdUpdate(
+    val elementType: ElementType,
+    val oldElementId: Long,
+    val newElementId: Long
+)
+
+fun createMapDataUpdates(
+    elements: Collection<Element>,
+    updates: Map<ElementKey, ElementUpdateAction>,
+    ignoreRelationTypes: Set<String?> = emptySet()
+): MapDataUpdates {
+    val updatedElements = mutableListOf<Element>()
+    val deletedElementKeys = mutableListOf<ElementKey>()
+    val idUpdates = mutableListOf<ElementIdUpdate>()
+
+    for (element in elements) {
+        if (element is Relation && element.tags["type"] in ignoreRelationTypes) continue
+
+        val newElement = element.update(updates)
+        if (newElement == null) {
+            deletedElementKeys.add(element.key)
+        } else if (newElement !== element) {
+            updatedElements.add(newElement)
+            if (element.id != newElement.id) {
+                idUpdates.add(ElementIdUpdate(element.type, element.id, newElement.id))
+            }
+        }
+    }
+
+    return MapDataUpdates(updatedElements, deletedElementKeys, idUpdates)
+}
+
+/**
+ * Apply the given updates to this element.
+ *
+ * @return null if the element was deleted, this if nothing was changed or an updated element
+ *         if anything way changed, e.g. the element's version or id, but also if any way node or
+ *         relation member('s id) was changed */
+private fun Element.update(updates: Map<ElementKey, ElementUpdateAction>): Element? {
+    val update = updates[key]
+    if (update is DeleteElement) return null
+    val u = update as UpdateElement? // kotlin doesn't infer this
+    return when (this) {
+        is Node -> update(u)
+        is Relation -> update(u, updates)
+        is Way -> update(u, updates)
+    }
+}
+
+private fun Node.update(update: UpdateElement?): Node {
+    return if (update != null) copy(id = update.newId, version = update.newVersion) else this
+}
+
+private fun Way.update(update: UpdateElement?, updates: Map<ElementKey, ElementUpdateAction>): Way {
+    val newNodeIds = nodeIds.mapNotNull { nodeId ->
+        when (val nodeUpdate = updates[ElementKey(ElementType.NODE, nodeId)]) {
+            DeleteElement -> null
+            is UpdateElement -> nodeUpdate.newId
+            null -> nodeId
+        }
+    }
+    if (newNodeIds == nodeIds && update == null) return this
+    return copy(
+        id = update?.newId ?: id,
+        version = update?.newVersion ?: version,
+        nodeIds = newNodeIds
+    )
+}
+
+private fun Relation.update(update: UpdateElement?, updates: Map<ElementKey, ElementUpdateAction>): Relation {
+    val newMembers = members.mapNotNull { member ->
+        when (val memberUpdate = updates[ElementKey(member.type, member.ref)]) {
+            DeleteElement -> null
+            is UpdateElement -> member.copy(ref = memberUpdate.newId)
+            null -> member
+        }
+    }
+    if (newMembers == members && update == null) return this
+    return copy(
+        id = update?.newId ?: id,
+        version = update?.newVersion ?: version,
+        members = newMembers
+    )
+}
diff --git a/app/src/main/java/de/westnordost/streetcomplete/data/osm/mapdata/MutableMapData.kt b/app/src/main/java/de/westnordost/streetcomplete/data/osm/mapdata/MutableMapData.kt
index 9d1ab0b9d6e..90c632c3043 100644
--- a/app/src/main/java/de/westnordost/streetcomplete/data/osm/mapdata/MutableMapData.kt
+++ b/app/src/main/java/de/westnordost/streetcomplete/data/osm/mapdata/MutableMapData.kt
@@ -1,14 +1,18 @@
 package de.westnordost.streetcomplete.data.osm.mapdata
 
-open class MutableMapData() : MapData {
+open class MutableMapData(
+    nodes: Collection<Node> = emptyList(),
+    ways: Collection<Way> = emptyList(),
+    relations: Collection<Relation> = emptyList()
+) : MapData {
 
     constructor(other: Iterable<Element>) : this() {
         addAll(other)
     }
 
-    protected val nodesById: MutableMap<Long, Node> = mutableMapOf()
-    protected val waysById: MutableMap<Long, Way> = mutableMapOf()
-    protected val relationsById: MutableMap<Long, Relation> = mutableMapOf()
+    private val nodesById: MutableMap<Long, Node> = nodes.associateByTo(HashMap()) { it.id }
+    private val waysById: MutableMap<Long, Way> = ways.associateByTo(HashMap()) { it.id }
+    private val relationsById: MutableMap<Long, Relation> = relations.associateByTo(HashMap()) { it.id }
     override var boundingBox: BoundingBox? = null
 
     override val nodes get() = nodesById.values
diff --git a/app/src/main/java/de/westnordost/streetcomplete/data/osm/mapdata/RemoteMapDataRepository.kt b/app/src/main/java/de/westnordost/streetcomplete/data/osm/mapdata/RemoteMapDataRepository.kt
new file mode 100644
index 00000000000..680908e3b81
--- /dev/null
+++ b/app/src/main/java/de/westnordost/streetcomplete/data/osm/mapdata/RemoteMapDataRepository.kt
@@ -0,0 +1,21 @@
+package de.westnordost.streetcomplete.data.osm.mapdata
+
+import kotlinx.coroutines.runBlocking
+
+/** Blocking wrapper around MapDataApiClient
+ *
+ *  TODO: In the long term, MapDataRepository itself should have a suspending interface, however
+ *        this is a quite far-reaching refactor, as MapDataRepository is used by quite some
+ *        blocking code. E.g. MapDataController and MapDataWithEditsSource should then also
+ *        suspend. */
+class RemoteMapDataRepository(private val mapDataApiClient: MapDataApiClient) : MapDataRepository {
+    override fun getNode(id: Long) = runBlocking { mapDataApiClient.getNode(id) }
+    override fun getWay(id: Long) = runBlocking { mapDataApiClient.getWay(id) }
+    override fun getRelation(id: Long) = runBlocking { mapDataApiClient.getRelation(id) }
+    override fun getWayComplete(id: Long) = runBlocking { mapDataApiClient.getWayComplete(id) }
+    override fun getRelationComplete(id: Long) = runBlocking { mapDataApiClient.getRelationComplete(id) }
+    override fun getWaysForNode(id: Long) = runBlocking { mapDataApiClient.getWaysForNode(id) }
+    override fun getRelationsForNode(id: Long) = runBlocking { mapDataApiClient.getRelationsForNode(id) }
+    override fun getRelationsForWay(id: Long) = runBlocking { mapDataApiClient.getRelationsForWay(id) }
+    override fun getRelationsForRelation(id: Long) = runBlocking { mapDataApiClient.getRelationsForRelation(id) }
+}
diff --git a/app/src/main/java/de/westnordost/streetcomplete/data/osm/mapdata/UpdatedElementsHandler.kt b/app/src/main/java/de/westnordost/streetcomplete/data/osm/mapdata/UpdatedElementsHandler.kt
deleted file mode 100644
index aad92ff8c6d..00000000000
--- a/app/src/main/java/de/westnordost/streetcomplete/data/osm/mapdata/UpdatedElementsHandler.kt
+++ /dev/null
@@ -1,79 +0,0 @@
-package de.westnordost.streetcomplete.data.osm.mapdata
-
-/** Reads the answer of an update map call on the OSM API. */
-class UpdatedElementsHandler(val ignoreRelationTypes: Set<String?> = emptySet()) {
-    private val nodeDiffs: MutableMap<Long, DiffElement> = mutableMapOf()
-    private val wayDiffs: MutableMap<Long, DiffElement> = mutableMapOf()
-    private val relationDiffs: MutableMap<Long, DiffElement> = mutableMapOf()
-
-    fun handle(d: DiffElement) {
-        when (d.type) {
-            ElementType.NODE -> nodeDiffs[d.clientId] = d
-            ElementType.WAY -> wayDiffs[d.clientId] = d
-            ElementType.RELATION -> relationDiffs[d.clientId] = d
-        }
-    }
-
-    fun getElementUpdates(elements: Collection<Element>): MapDataUpdates {
-        val updatedElements = mutableListOf<Element>()
-        val deletedElementKeys = mutableListOf<ElementKey>()
-        val idUpdates = mutableListOf<ElementIdUpdate>()
-        for (element in elements) {
-            if (element is Relation && element.tags["type"] in ignoreRelationTypes) {
-                continue
-            }
-            val diff = getDiff(element.type, element.id) ?: continue
-            if (diff.serverId != null && diff.serverVersion != null) {
-                updatedElements.add(createUpdatedElement(element, diff.serverId, diff.serverVersion))
-            } else {
-                deletedElementKeys.add(ElementKey(diff.type, diff.clientId))
-            }
-            if (diff.clientId != diff.serverId && diff.serverId != null) {
-                idUpdates.add(ElementIdUpdate(diff.type, diff.clientId, diff.serverId))
-            }
-        }
-        return MapDataUpdates(updatedElements, deletedElementKeys, idUpdates)
-    }
-
-    private fun getDiff(type: ElementType, id: Long): DiffElement? = when (type) {
-        ElementType.NODE -> nodeDiffs[id]
-        ElementType.WAY -> wayDiffs[id]
-        ElementType.RELATION -> relationDiffs[id]
-    }
-
-    private fun createUpdatedElement(element: Element, newId: Long, newVersion: Int): Element =
-        when (element) {
-            is Node -> createUpdatedNode(element, newId, newVersion)
-            is Way -> createUpdatedWay(element, newId, newVersion)
-            is Relation -> createUpdatedRelation(element, newId, newVersion)
-        }
-
-    private fun createUpdatedNode(node: Node, newId: Long, newVersion: Int): Node =
-        Node(newId, node.position, HashMap(node.tags), newVersion, node.timestampEdited)
-
-    private fun createUpdatedWay(way: Way, newId: Long, newVersion: Int): Way {
-        val newNodeIds = ArrayList<Long>(way.nodeIds.size)
-        for (nodeId in way.nodeIds) {
-            val diff = nodeDiffs[nodeId]
-            if (diff == null) {
-                newNodeIds.add(nodeId)
-            } else if (diff.serverId != null) {
-                newNodeIds.add(diff.serverId)
-            }
-        }
-        return Way(newId, newNodeIds, HashMap(way.tags), newVersion, way.timestampEdited)
-    }
-
-    private fun createUpdatedRelation(relation: Relation, newId: Long, newVersion: Int): Relation {
-        val newRelationMembers = ArrayList<RelationMember>(relation.members.size)
-        for (member in relation.members) {
-            val diff = getDiff(member.type, member.ref)
-            if (diff == null) {
-                newRelationMembers.add(RelationMember(member.type, member.ref, member.role))
-            } else if (diff.serverId != null) {
-                newRelationMembers.add(RelationMember(member.type, diff.serverId, member.role))
-            }
-        }
-        return Relation(newId, newRelationMembers, HashMap(relation.tags), newVersion, relation.timestampEdited)
-    }
-}
diff --git a/app/src/main/java/de/westnordost/streetcomplete/data/osmnotes/AvatarsDownloader.kt b/app/src/main/java/de/westnordost/streetcomplete/data/osmnotes/AvatarsDownloader.kt
index 939871ab5ea..0aceaf8c81b 100644
--- a/app/src/main/java/de/westnordost/streetcomplete/data/osmnotes/AvatarsDownloader.kt
+++ b/app/src/main/java/de/westnordost/streetcomplete/data/osmnotes/AvatarsDownloader.kt
@@ -1,6 +1,6 @@
 package de.westnordost.streetcomplete.data.osmnotes
 
-import de.westnordost.osmapi.user.UserApi
+import de.westnordost.streetcomplete.data.user.UserApiClient
 import de.westnordost.streetcomplete.util.ktx.format
 import de.westnordost.streetcomplete.util.ktx.nowAsEpochMilliseconds
 import de.westnordost.streetcomplete.util.logs.Log
@@ -15,7 +15,7 @@ import java.io.File
 /** Downloads and stores the OSM avatars of users */
 class AvatarsDownloader(
     private val httpClient: HttpClient,
-    private val userApi: UserApi,
+    private val userApi: UserApiClient,
     private val cacheDir: File
 ) {
 
@@ -36,7 +36,7 @@ class AvatarsDownloader(
         Log.i(TAG, "Downloaded ${userIds.size} avatar images in ${seconds.format(1)}s")
     }
 
-    private fun getProfileImageUrl(userId: Long): String? =
+    private suspend fun getProfileImageUrl(userId: Long): String? =
         try {
             userApi.get(userId)?.profileImageUrl
         } catch (e: Exception) {
diff --git a/app/src/main/java/de/westnordost/streetcomplete/data/osmnotes/NotesApi.kt b/app/src/main/java/de/westnordost/streetcomplete/data/osmnotes/NotesApi.kt
deleted file mode 100644
index 39272e1aa05..00000000000
--- a/app/src/main/java/de/westnordost/streetcomplete/data/osmnotes/NotesApi.kt
+++ /dev/null
@@ -1,68 +0,0 @@
-package de.westnordost.streetcomplete.data.osmnotes
-
-import de.westnordost.streetcomplete.data.AuthorizationException
-import de.westnordost.streetcomplete.data.ConflictException
-import de.westnordost.streetcomplete.data.ConnectionException
-import de.westnordost.streetcomplete.data.download.QueryTooBigException
-import de.westnordost.streetcomplete.data.osm.mapdata.BoundingBox
-import de.westnordost.streetcomplete.data.osm.mapdata.LatLon
-
-/**
- * Creates, comments, closes, reopens and search for notes.
- * All interactions with this class require an OsmConnection with a logged in user.
- */
-interface NotesApi {
-    /**
-     * Create a new note at the given location
-     *
-     * @param pos position of the note.
-     * @param text text for the new note. Must not be empty.
-     *
-     * @throws AuthorizationException if this application is not authorized to write notes
-     *                                (Permission.WRITE_NOTES)
-     * @throws ConnectionException if a temporary network connection problem occurs
-     *
-     * @return the new note
-     */
-    fun create(pos: LatLon, text: String): Note
-
-    /**
-     * @param id id of the note
-     * @param text comment to be added to the note. Must not be empty
-     *
-     * @throws ConflictException if the note has already been closed or doesn't exist (anymore).
-     * @throws AuthorizationException if this application is not authorized to write notes
-     *                                (Permission.WRITE_NOTES)
-     * @throws ConnectionException if a temporary network connection problem occurs
-     *
-     * @return the updated commented note
-     */
-    fun comment(id: Long, text: String): Note
-
-    /**
-     * @param id id of the note
-     *
-     * @throws ConnectionException if a temporary network connection problem occurs
-     *
-     * @return the note with the given id. null if the note with that id does not exist (anymore).
-     */
-    fun get(id: Long): Note?
-
-    /**
-     * Retrieve those notes in the given area that match the given search string
-     *
-     * @param bounds the area within the notes should be queried. This is usually limited at 25
-     *               square degrees. Check the server capabilities.
-     * @param limit number of entries returned at maximum. Any value between 1 and 10000
-     * @param hideClosedNoteAfter number of days until a closed note should not be shown anymore.
-     *                            -1 means that all notes should be returned, 0 that only open notes
-     *                            are returned.
-     *
-     * @throws QueryTooBigException if the bounds area is too large
-     * @throws IllegalArgumentException if the bounds cross the 180th meridian
-     * @throws ConnectionException if a temporary network connection problem occurs
-     *
-     * @return the incoming notes
-     */
-    fun getAll(bounds: BoundingBox, limit: Int, hideClosedNoteAfter: Int): List<Note>
-}
diff --git a/app/src/main/java/de/westnordost/streetcomplete/data/osmnotes/NotesApiClient.kt b/app/src/main/java/de/westnordost/streetcomplete/data/osmnotes/NotesApiClient.kt
new file mode 100644
index 00000000000..186463e8ecd
--- /dev/null
+++ b/app/src/main/java/de/westnordost/streetcomplete/data/osmnotes/NotesApiClient.kt
@@ -0,0 +1,142 @@
+package de.westnordost.streetcomplete.data.osmnotes
+
+import de.westnordost.streetcomplete.data.ConnectionException
+import de.westnordost.streetcomplete.data.QueryTooBigException
+import de.westnordost.streetcomplete.data.osm.mapdata.BoundingBox
+import de.westnordost.streetcomplete.data.osm.mapdata.LatLon
+import de.westnordost.streetcomplete.data.ConflictException
+import de.westnordost.streetcomplete.data.AuthorizationException
+import de.westnordost.streetcomplete.data.osm.mapdata.toOsmApiString
+import de.westnordost.streetcomplete.data.user.UserLoginSource
+import de.westnordost.streetcomplete.data.wrapApiClientExceptions
+import de.westnordost.streetcomplete.util.ktx.format
+import io.ktor.client.HttpClient
+import io.ktor.client.call.body
+import io.ktor.client.plugins.ClientRequestException
+import io.ktor.client.plugins.expectSuccess
+import io.ktor.client.request.bearerAuth
+import io.ktor.client.request.get
+import io.ktor.client.request.parameter
+import io.ktor.client.request.post
+import io.ktor.http.HttpStatusCode
+
+/**
+ * Creates, comments, closes, reopens and search for notes.
+ */
+class NotesApiClient(
+    private val httpClient: HttpClient,
+    private val baseUrl: String,
+    private val userLoginSource: UserLoginSource,
+    private val notesApiParser: NotesApiParser
+) {
+    /**
+     * Create a new note at the given location
+     *
+     * @param pos position of the note.
+     * @param text text for the new note. Must not be empty.
+     *
+     * @throws AuthorizationException if this application is not authorized to write notes
+     *                                (scope "write_notes")
+     * @throws ConnectionException if a temporary network connection problem occurs
+     *
+     * @return the new note
+     */
+    suspend fun create(pos: LatLon, text: String): Note = wrapApiClientExceptions {
+        val response = httpClient.post(baseUrl + "notes") {
+            userLoginSource.accessToken?.let { bearerAuth(it) }
+            parameter("lat", pos.latitude.format(7))
+            parameter("lon", pos.longitude.format(7))
+            parameter("text", text)
+            expectSuccess = true
+        }
+        return notesApiParser.parseNotes(response.body<String>()).single()
+    }
+
+    /**
+     * @param id id of the note
+     * @param text comment to be added to the note. Must not be empty
+     *
+     * @throws ConflictException if the note has already been closed or doesn't exist (anymore).
+     * @throws AuthorizationException if this application is not authorized to write notes
+     *                                (scope "write_notes")
+     * @throws ConnectionException if a temporary network connection problem occurs
+     *
+     * @return the updated commented note
+     */
+    suspend fun comment(id: Long, text: String): Note = wrapApiClientExceptions {
+        try {
+            val response = httpClient.post(baseUrl + "notes/$id/comment") {
+                userLoginSource.accessToken?.let { bearerAuth(it) }
+                parameter("text", text)
+                expectSuccess = true
+            }
+            return notesApiParser.parseNotes(response.body<String>()).single()
+        } catch (e: ClientRequestException) {
+            when (e.response.status) {
+                // hidden by moderator, does not exist (yet), has already been closed
+                HttpStatusCode.Gone, HttpStatusCode.NotFound, HttpStatusCode.Conflict -> {
+                    throw ConflictException(e.message, e)
+                }
+                else -> throw e
+            }
+        }
+    }
+
+    /**
+     * @param id id of the note
+     *
+     * @throws ConnectionException if a temporary network connection problem occurs
+     *
+     * @return the note with the given id. null if the note with that id does not exist (anymore).
+     */
+    suspend fun get(id: Long): Note? = wrapApiClientExceptions {
+        try {
+            val response = httpClient.get(baseUrl + "notes/$id") { expectSuccess = true }
+            val body = response.body<String>()
+            return notesApiParser.parseNotes(body).singleOrNull()
+        } catch (e: ClientRequestException) {
+            when (e.response.status) {
+                // hidden by moderator, does not exist (yet)
+                HttpStatusCode.Gone, HttpStatusCode.NotFound -> return null
+                else -> throw e
+            }
+        }
+    }
+
+    /**
+     * Retrieve all open notes in the given area
+     *
+     * @param bounds the area within the notes should be queried. This is usually limited at 25
+     *               square degrees. Check the server capabilities.
+     * @param limit number of entries returned at maximum. Any value between 1 and 10000
+     *
+     * @throws QueryTooBigException if the bounds area or the limit is too large
+     * @throws IllegalArgumentException if the bounds cross the 180th meridian
+     * @throws ConnectionException if a temporary network connection problem occurs
+     *
+     * @return the incoming notes
+     */
+    suspend fun getAllOpen(bounds: BoundingBox, limit: Int? = null): List<Note> = wrapApiClientExceptions {
+        if (bounds.crosses180thMeridian) {
+            throw IllegalArgumentException("Bounding box crosses 180th meridian")
+        }
+
+        try {
+            val response = httpClient.get(baseUrl + "notes") {
+                userLoginSource.accessToken?.let { bearerAuth(it) }
+                parameter("bbox", bounds.toOsmApiString())
+                parameter("limit", limit)
+                parameter("closed", 0)
+                expectSuccess = true
+            }
+            val body = response.body<String>()
+            return notesApiParser.parseNotes(body)
+        } catch (e: ClientRequestException) {
+            if (e.response.status == HttpStatusCode.BadRequest) {
+                throw QueryTooBigException(e.message, e)
+            } else {
+                throw e
+            }
+        }
+    }
+}
diff --git a/app/src/main/java/de/westnordost/streetcomplete/data/osmnotes/NotesApiImpl.kt b/app/src/main/java/de/westnordost/streetcomplete/data/osmnotes/NotesApiImpl.kt
deleted file mode 100644
index a10fbe7e9c0..00000000000
--- a/app/src/main/java/de/westnordost/streetcomplete/data/osmnotes/NotesApiImpl.kt
+++ /dev/null
@@ -1,109 +0,0 @@
-package de.westnordost.streetcomplete.data.osmnotes
-
-import de.westnordost.osmapi.OsmConnection
-import de.westnordost.osmapi.common.errors.OsmApiException
-import de.westnordost.osmapi.common.errors.OsmApiReadResponseException
-import de.westnordost.osmapi.common.errors.OsmAuthorizationException
-import de.westnordost.osmapi.common.errors.OsmConflictException
-import de.westnordost.osmapi.common.errors.OsmConnectionException
-import de.westnordost.osmapi.common.errors.OsmNotFoundException
-import de.westnordost.osmapi.common.errors.OsmQueryTooBigException
-import de.westnordost.osmapi.map.data.OsmLatLon
-import de.westnordost.streetcomplete.data.AuthorizationException
-import de.westnordost.streetcomplete.data.ConflictException
-import de.westnordost.streetcomplete.data.ConnectionException
-import de.westnordost.streetcomplete.data.download.QueryTooBigException
-import de.westnordost.streetcomplete.data.osm.mapdata.BoundingBox
-import de.westnordost.streetcomplete.data.osm.mapdata.LatLon
-import de.westnordost.streetcomplete.data.user.User
-import de.westnordost.osmapi.map.data.BoundingBox as OsmApiBoundingBox
-import de.westnordost.osmapi.notes.Note as OsmApiNote
-import de.westnordost.osmapi.notes.NoteComment as OsmApiNoteComment
-import de.westnordost.osmapi.notes.NotesApi as OsmApiNotesApi
-import de.westnordost.osmapi.user.User as OsmApiUser
-
-class NotesApiImpl(osm: OsmConnection) : NotesApi {
-    private val api: OsmApiNotesApi = OsmApiNotesApi(osm)
-
-    override fun create(pos: LatLon, text: String): Note = wrapExceptions {
-        api.create(OsmLatLon(pos.latitude, pos.longitude), text).toNote()
-    }
-
-    override fun comment(id: Long, text: String): Note =
-        try {
-            wrapExceptions { api.comment(id, text).toNote() }
-        } catch (e: OsmNotFoundException) {
-            // someone else already closed the note -> our contribution is probably worthless
-            throw ConflictException(e.message, e)
-        }
-
-    override fun get(id: Long): Note? = wrapExceptions { api.get(id)?.toNote() }
-
-    override fun getAll(bounds: BoundingBox, limit: Int, hideClosedNoteAfter: Int) = wrapExceptions {
-        val notes = ArrayList<Note>()
-        api.getAll(
-            OsmApiBoundingBox(
-                bounds.min.latitude, bounds.min.longitude,
-                bounds.max.latitude, bounds.max.longitude
-            ),
-            null,
-            { notes.add(it.toNote()) },
-            limit,
-            hideClosedNoteAfter
-        )
-        notes
-    }
-}
-
-private inline fun <T> wrapExceptions(block: () -> T): T =
-    try {
-        block()
-    } catch (e: OsmAuthorizationException) {
-        throw AuthorizationException(e.message, e)
-    } catch (e: OsmConflictException) {
-        throw ConflictException(e.message, e)
-    } catch (e: OsmQueryTooBigException) {
-        throw QueryTooBigException(e.message, e)
-    } catch (e: OsmConnectionException) {
-        throw ConnectionException(e.message, e)
-    } catch (e: OsmApiReadResponseException) {
-        // probably a temporary connection error
-        throw ConnectionException(e.message, e)
-    } catch (e: OsmApiException) {
-        // request timeout is a temporary connection error
-        throw if (e.errorCode == 408) ConnectionException(e.message, e) else e
-    }
-
-private fun OsmApiNote.toNote() = Note(
-    LatLon(position.latitude, position.longitude),
-    id,
-    createdAt.toEpochMilli(),
-    closedAt?.toEpochMilli(),
-    status.toNoteStatus(),
-    comments.map { it.toNoteComment() }
-)
-
-private fun OsmApiNote.Status.toNoteStatus() = when (this) {
-    OsmApiNote.Status.OPEN   -> Note.Status.OPEN
-    OsmApiNote.Status.CLOSED -> Note.Status.CLOSED
-    OsmApiNote.Status.HIDDEN -> Note.Status.HIDDEN
-    else -> throw NoSuchFieldError()
-}
-
-private fun OsmApiNoteComment.toNoteComment() = NoteComment(
-    date.toEpochMilli(),
-    action.toNoteCommentAction(),
-    text,
-    user?.toUser()
-)
-
-private fun OsmApiNoteComment.Action.toNoteCommentAction() = when (this) {
-    OsmApiNoteComment.Action.OPENED     -> NoteComment.Action.OPENED
-    OsmApiNoteComment.Action.COMMENTED  -> NoteComment.Action.COMMENTED
-    OsmApiNoteComment.Action.CLOSED     -> NoteComment.Action.CLOSED
-    OsmApiNoteComment.Action.REOPENED   -> NoteComment.Action.REOPENED
-    OsmApiNoteComment.Action.HIDDEN     -> NoteComment.Action.HIDDEN
-    else -> throw NoSuchFieldError()
-}
-
-private fun OsmApiUser.toUser() = User(id, displayName)
diff --git a/app/src/main/java/de/westnordost/streetcomplete/data/osmnotes/NotesApiParser.kt b/app/src/main/java/de/westnordost/streetcomplete/data/osmnotes/NotesApiParser.kt
new file mode 100644
index 00000000000..3d9eee3f71e
--- /dev/null
+++ b/app/src/main/java/de/westnordost/streetcomplete/data/osmnotes/NotesApiParser.kt
@@ -0,0 +1,93 @@
+package de.westnordost.streetcomplete.data.osmnotes
+
+import de.westnordost.streetcomplete.data.osm.mapdata.LatLon
+import de.westnordost.streetcomplete.data.user.User
+import de.westnordost.streetcomplete.util.ktx.attribute
+import kotlinx.datetime.LocalDate
+import kotlinx.datetime.LocalTime
+import kotlinx.datetime.format.DateTimeComponents
+import kotlinx.datetime.format.char
+import kotlinx.serialization.SerializationException
+import nl.adaptivity.xmlutil.EventType.*
+import nl.adaptivity.xmlutil.XmlReader
+import nl.adaptivity.xmlutil.xmlStreaming
+
+class NotesApiParser {
+    fun parseNotes(osmXml: String): List<Note> =
+        xmlStreaming.newReader(osmXml).parseNotes()
+}
+
+private fun XmlReader.parseNotes(): List<Note> = try {
+    val result = ArrayList<Note>()
+
+    var note: ApiNote? = null
+    var comment: ApiNoteComment? = null
+    val names = ArrayList<String>()
+
+    forEach { when (it) {
+        START_ELEMENT -> {
+            when (localName) {
+                "note" -> note = ApiNote(LatLon(attribute("lat").toDouble(), attribute("lon").toDouble()))
+                "comment" -> comment = ApiNoteComment()
+            }
+            names.add(localName)
+        }
+        TEXT -> when (names.lastOrNull()) {
+            // note
+            "id" -> note?.id = text.toLong()
+            "date_created" -> note?.timestampCreated = parseTimestamp(text)
+            "date_closed" -> note?.timestampClosed = parseTimestamp(text)
+            "status" -> note?.status = Note.Status.valueOf(text.uppercase())
+            // comment
+            "date" -> comment?.date = parseTimestamp(text)
+            "action" -> comment?.action = NoteComment.Action.valueOf(text.uppercase())
+            "text" -> comment?.text = text
+            "uid" -> comment?.uid = text.toLong()
+            "user" -> comment?.user = text
+        }
+        END_ELEMENT -> {
+            when (localName) {
+                "note" -> {
+                    val n = note!!
+                    result.add(Note(n.position, n.id!!, n.timestampCreated!!, n.timestampClosed, n.status!!, n.comments))
+                }
+                "comment" -> {
+                    val c = comment!!
+                    val cUser = if (c.user != null && c.uid != null) User(c.uid!!, c.user!!) else null
+                    note?.comments?.add(NoteComment(c.date!!, c.action!!, c.text, cUser))
+                }
+            }
+            names.removeLastOrNull()
+        }
+        else -> {}
+    } }
+    result
+} catch (e: Exception) { throw SerializationException(e) }
+
+private data class ApiNote(
+    val position: LatLon,
+    var id: Long? = null,
+    var timestampCreated: Long? = null,
+    var timestampClosed: Long? = null,
+    var status: Note.Status? = null,
+    val comments: MutableList<NoteComment> = ArrayList(),
+)
+
+private data class ApiNoteComment(
+    var date: Long? = null,
+    var action: NoteComment.Action? = null,
+    var text: String? = null,
+    var uid: Long? = null,
+    var user: String? = null,
+)
+
+private val dateFormat = DateTimeComponents.Format {
+    date(LocalDate.Formats.ISO)
+    char(' ')
+    time(LocalTime.Formats.ISO)
+    char(' ')
+    timeZoneId()
+}
+
+private fun parseTimestamp(date: String): Long =
+    dateFormat.parse(date).toInstantUsingOffset().toEpochMilliseconds()
diff --git a/app/src/main/java/de/westnordost/streetcomplete/data/osmnotes/NotesDownloader.kt b/app/src/main/java/de/westnordost/streetcomplete/data/osmnotes/NotesDownloader.kt
index 2ef54e69627..1202a076a19 100644
--- a/app/src/main/java/de/westnordost/streetcomplete/data/osmnotes/NotesDownloader.kt
+++ b/app/src/main/java/de/westnordost/streetcomplete/data/osmnotes/NotesDownloader.kt
@@ -8,16 +8,16 @@ import kotlinx.coroutines.Dispatchers
 import kotlinx.coroutines.withContext
 import kotlinx.coroutines.yield
 
-/** Takes care of downloading notes and referenced avatar pictures into persistent storage */
+/** Takes care of downloading notes into persistent storage */
 class NotesDownloader(
-    private val notesApi: NotesApi,
+    private val notesApi: NotesApiClient,
     private val noteController: NoteController
 ) {
-    suspend fun download(bbox: BoundingBox) = withContext(Dispatchers.IO) {
+    suspend fun download(bbox: BoundingBox) {
         val time = nowAsEpochMilliseconds()
 
         val notes = notesApi
-            .getAll(bbox, 10000, 0)
+            .getAllOpen(bbox, 10000)
             // exclude invalid notes (#1338)
             .filter { it.comments.isNotEmpty() }
 
@@ -26,7 +26,7 @@ class NotesDownloader(
 
         yield()
 
-        noteController.putAllForBBox(bbox, notes)
+        withContext(Dispatchers.IO) { noteController.putAllForBBox(bbox, notes) }
     }
 
     companion object {
diff --git a/app/src/main/java/de/westnordost/streetcomplete/data/osmnotes/NotesModule.kt b/app/src/main/java/de/westnordost/streetcomplete/data/osmnotes/NotesModule.kt
index 183a8082929..c04c0186453 100644
--- a/app/src/main/java/de/westnordost/streetcomplete/data/osmnotes/NotesModule.kt
+++ b/app/src/main/java/de/westnordost/streetcomplete/data/osmnotes/NotesModule.kt
@@ -12,7 +12,7 @@ val notesModule = module {
     factory { AvatarsInNotesUpdater(get()) }
     factory { NoteDao(get()) }
     factory { NotesDownloader(get(), get()) }
-    factory { StreetCompleteImageUploader(get(), ApplicationConstants.SC_PHOTO_SERVICE_URL) }
+    factory { PhotoServiceApiClient(get(), ApplicationConstants.SC_PHOTO_SERVICE_URL) }
 
     single {
         NoteController(get()).apply {
diff --git a/app/src/main/java/de/westnordost/streetcomplete/data/osmnotes/PhotoServiceApiClient.kt b/app/src/main/java/de/westnordost/streetcomplete/data/osmnotes/PhotoServiceApiClient.kt
new file mode 100644
index 00000000000..f3c863aae39
--- /dev/null
+++ b/app/src/main/java/de/westnordost/streetcomplete/data/osmnotes/PhotoServiceApiClient.kt
@@ -0,0 +1,83 @@
+package de.westnordost.streetcomplete.data.osmnotes
+
+import de.westnordost.streetcomplete.data.ConnectionException
+import de.westnordost.streetcomplete.data.wrapApiClientExceptions
+import io.ktor.client.HttpClient
+import io.ktor.client.call.body
+import io.ktor.client.plugins.ClientRequestException
+import io.ktor.client.plugins.expectSuccess
+import io.ktor.client.request.header
+import io.ktor.client.request.post
+import io.ktor.client.request.setBody
+import io.ktor.http.ContentType
+import io.ktor.http.HttpStatusCode
+import io.ktor.http.contentType
+import io.ktor.http.defaultForFile
+import io.ktor.http.isSuccess
+import io.ktor.util.cio.readChannel
+import io.ktor.utils.io.errors.IOException
+import kotlinx.serialization.SerialName
+import kotlinx.serialization.Serializable
+import kotlinx.serialization.json.Json
+import java.io.File
+
+/** Upload and activate a list of image paths to an instance of the
+ *  https://github.com/streetcomplete/sc-photo-service
+ */
+class PhotoServiceApiClient(
+    private val httpClient: HttpClient,
+    private val baseUrl: String
+) {
+    private val json = Json { ignoreUnknownKeys = true }
+
+    /** Upload list of images.
+     *
+     *  @throws ConnectionException on connection or server error */
+    suspend fun upload(imagePaths: List<String>): List<String> = wrapApiClientExceptions {
+        val imageLinks = ArrayList<String>()
+
+        for (path in imagePaths) {
+            val file = File(path)
+            if (!file.exists()) continue
+
+            val response = httpClient.post(baseUrl + "upload.php") {
+                contentType(ContentType.defaultForFile(file))
+                header("Content-Transfer-Encoding", "binary")
+                setBody(file.readChannel())
+                expectSuccess = true
+            }
+
+            val body = response.body<String>()
+            val parsedResponse = json.decodeFromString<PhotoUploadResponse>(body)
+            imageLinks.add(parsedResponse.futureUrl)
+        }
+
+        return imageLinks
+    }
+
+    /** Activate the images in the given note.
+     *
+     *  @throws ConnectionException on connection or server error */
+    suspend fun activate(noteId: Long): Unit = wrapApiClientExceptions {
+        try {
+            httpClient.post(baseUrl + "activate.php") {
+                contentType(ContentType.Application.Json)
+                setBody("{\"osm_note_id\": $noteId}")
+                expectSuccess = true
+            }
+        } catch (e: ClientRequestException) {
+            if (e.response.status == HttpStatusCode.Gone) {
+                // it's gone if the note does not exist anymore. That's okay, it should only fail
+                // if we might want to try again later.
+            } else {
+                throw e
+            }
+        }
+    }
+}
+
+@Serializable
+private data class PhotoUploadResponse(
+    @SerialName("future_url")
+    val futureUrl: String
+)
diff --git a/app/src/main/java/de/westnordost/streetcomplete/data/osmnotes/StreetCompleteImageUploader.kt b/app/src/main/java/de/westnordost/streetcomplete/data/osmnotes/StreetCompleteImageUploader.kt
deleted file mode 100644
index 11640615c2a..00000000000
--- a/app/src/main/java/de/westnordost/streetcomplete/data/osmnotes/StreetCompleteImageUploader.kt
+++ /dev/null
@@ -1,115 +0,0 @@
-package de.westnordost.streetcomplete.data.osmnotes
-
-import de.westnordost.streetcomplete.data.ConnectionException
-import io.ktor.client.HttpClient
-import io.ktor.client.call.body
-import io.ktor.client.request.header
-import io.ktor.client.request.post
-import io.ktor.client.request.setBody
-import io.ktor.http.ContentType
-import io.ktor.http.HttpStatusCode
-import io.ktor.http.contentType
-import io.ktor.http.defaultForFile
-import io.ktor.http.isSuccess
-import io.ktor.util.cio.readChannel
-import io.ktor.utils.io.errors.IOException
-import kotlinx.serialization.SerialName
-import kotlinx.serialization.Serializable
-import kotlinx.serialization.SerializationException
-import kotlinx.serialization.json.Json
-import java.io.File
-
-@Serializable
-private data class PhotoUploadResponse(
-    @SerialName("future_url")
-    val futureUrl: String
-)
-
-/** Upload and activate a list of image paths to an instance of the
- * <a href="https://github.com/streetcomplete/sc-photo-service">StreetComplete image hosting service</a>
- */
-class StreetCompleteImageUploader(
-    private val httpClient: HttpClient,
-    private val baseUrl: String
-) {
-    private val json = Json {
-        ignoreUnknownKeys = true
-    }
-
-    /** Upload list of images.
-     *
-     *  @throws ImageUploadServerException when there was a server error on upload (server error)
-     *  @throws ImageUploadClientException when the server rejected the upload request (client error)
-     *  @throws ConnectionException if it is currently not reachable (no internet etc) */
-    suspend fun upload(imagePaths: List<String>): List<String> {
-        val imageLinks = ArrayList<String>()
-
-        for (path in imagePaths) {
-            val file = File(path)
-            if (!file.exists()) continue
-
-            try {
-                val response = httpClient.post(baseUrl + "upload.php") {
-                    contentType(ContentType.defaultForFile(file))
-                    header("Content-Transfer-Encoding", "binary")
-                    setBody(file.readChannel())
-                }
-
-                val status = response.status
-                val body = response.body<String>()
-                if (status.isSuccess()) {
-                    try {
-                        val parsedResponse = json.decodeFromString<PhotoUploadResponse>(body)
-                        imageLinks.add(parsedResponse.futureUrl)
-                    } catch (e: SerializationException) {
-                        throw ImageUploadServerException("Upload Failed: Unexpected response \"$body\"")
-                    }
-                } else {
-                    if (status.value in 500..599) {
-                        throw ImageUploadServerException("Upload failed: Error code $status, Message: \"$body\"")
-                    } else {
-                        throw ImageUploadClientException("Upload failed: Error code $status, Message: \"$body\"")
-                    }
-                }
-            } catch (e: IOException) {
-                throw ConnectionException("Upload failed", e)
-            }
-        }
-
-        return imageLinks
-    }
-
-    /** Activate the images in the given note.
-     *  @throws ImageUploadServerException when there was a server error on upload (server error)
-     *  @throws ImageUploadClientException when the server rejected the upload request (client error)
-     *  @throws ConnectionException if it is currently not reachable (no internet etc)  */
-    suspend fun activate(noteId: Long) {
-        try {
-            val response = httpClient.post(baseUrl + "activate.php") {
-                contentType(ContentType.Application.Json)
-                setBody("{\"osm_note_id\": $noteId}")
-            }
-
-            val status = response.status
-            if (status == HttpStatusCode.Gone) {
-                // it's gone if the note does not exist anymore. That's okay, it should only fail
-                // if we might want to try again later.
-            } else if (!status.isSuccess()) {
-                val error = response.body<String>()
-                if (status.value in 500..599) {
-                    throw ImageUploadServerException("Error code $status, Message: \"$error\"")
-                } else {
-                    throw ImageUploadClientException("Error code $status, Message: \"$error\"")
-                }
-            }
-        } catch (e: IOException) {
-            throw ConnectionException("", e)
-        }
-    }
-}
-
-class ImageUploadServerException(message: String? = null, cause: Throwable? = null) :
-    RuntimeException(message, cause)
-
-class ImageUploadClientException(message: String? = null, cause: Throwable? = null) :
-    RuntimeException(message, cause)
diff --git a/app/src/main/java/de/westnordost/streetcomplete/data/osmnotes/edits/NoteEditsUploader.kt b/app/src/main/java/de/westnordost/streetcomplete/data/osmnotes/edits/NoteEditsUploader.kt
index efe57f5148e..b3d89415eed 100644
--- a/app/src/main/java/de/westnordost/streetcomplete/data/osmnotes/edits/NoteEditsUploader.kt
+++ b/app/src/main/java/de/westnordost/streetcomplete/data/osmnotes/edits/NoteEditsUploader.kt
@@ -2,16 +2,15 @@ package de.westnordost.streetcomplete.data.osmnotes.edits
 
 import de.westnordost.streetcomplete.data.ConflictException
 import de.westnordost.streetcomplete.data.osmnotes.NoteController
-import de.westnordost.streetcomplete.data.osmnotes.NotesApi
-import de.westnordost.streetcomplete.data.osmnotes.StreetCompleteImageUploader
+import de.westnordost.streetcomplete.data.osmnotes.NotesApiClient
+import de.westnordost.streetcomplete.data.osmnotes.PhotoServiceApiClient
 import de.westnordost.streetcomplete.data.osmnotes.deleteImages
 import de.westnordost.streetcomplete.data.osmnotes.edits.NoteEditAction.COMMENT
 import de.westnordost.streetcomplete.data.osmnotes.edits.NoteEditAction.CREATE
 import de.westnordost.streetcomplete.data.osmtracks.Trackpoint
-import de.westnordost.streetcomplete.data.osmtracks.TracksApi
+import de.westnordost.streetcomplete.data.osmtracks.TracksApiClient
 import de.westnordost.streetcomplete.data.upload.OnUploadedChangeListener
 import de.westnordost.streetcomplete.data.user.UserDataSource
-import de.westnordost.streetcomplete.util.ktx.truncate
 import de.westnordost.streetcomplete.util.logs.Log
 import io.ktor.http.encodeURLPathPart
 import kotlinx.coroutines.CoroutineName
@@ -26,9 +25,9 @@ class NoteEditsUploader(
     private val noteEditsController: NoteEditsController,
     private val noteController: NoteController,
     private val userDataSource: UserDataSource,
-    private val notesApi: NotesApi,
-    private val tracksApi: TracksApi,
-    private val imageUploader: StreetCompleteImageUploader
+    private val notesApi: NotesApiClient,
+    private val tracksApi: TracksApiClient,
+    private val imageUploader: PhotoServiceApiClient
 ) {
     var uploadedChangeListener: OnUploadedChangeListener? = null
 
@@ -126,12 +125,12 @@ class NoteEditsUploader(
         return ""
     }
 
-    private fun uploadAndGetAttachedTrackText(
+    private suspend fun uploadAndGetAttachedTrackText(
         trackpoints: List<Trackpoint>,
         noteText: String?
     ): String {
         if (trackpoints.isEmpty()) return ""
-        val trackId = tracksApi.create(trackpoints, noteText?.truncate(255))
+        val trackId = tracksApi.create(trackpoints, noteText)
         val encodedUsername = userDataSource.userName!!.encodeURLPathPart()
         return "\n\nGPS Trace: https://www.openstreetmap.org/user/$encodedUsername/traces/$trackId\n"
     }
diff --git a/app/src/main/java/de/westnordost/streetcomplete/data/osmtracks/TracksApi.kt b/app/src/main/java/de/westnordost/streetcomplete/data/osmtracks/TracksApi.kt
deleted file mode 100644
index 3f2c33026e7..00000000000
--- a/app/src/main/java/de/westnordost/streetcomplete/data/osmtracks/TracksApi.kt
+++ /dev/null
@@ -1,26 +0,0 @@
-package de.westnordost.streetcomplete.data.osmtracks
-
-import de.westnordost.streetcomplete.data.AuthorizationException
-import de.westnordost.streetcomplete.data.ConnectionException
-
-/**
- * Creates GPS / GPX trackpoint histories
- */
-interface TracksApi {
-
-    /**
-     * Create a new GPX track history
-     *
-     * @param trackpoints history of recorded trackpoints
-     * @param noteText optional text appended to the track
-     *
-     * @throws AuthorizationException if this application is not authorized to write traces
-     *                                (Permission.WRITE_GPS_TRACES)
-     * @throws ConnectionException if a temporary network connection problem occurs
-     *
-     * @throws IllegalArgumentException if noteText is longer than 255 characters
-     *
-     * @return id of the new track
-     */
-    fun create(trackpoints: List<Trackpoint>, noteText: String?): Long
-}
diff --git a/app/src/main/java/de/westnordost/streetcomplete/data/osmtracks/TracksApiClient.kt b/app/src/main/java/de/westnordost/streetcomplete/data/osmtracks/TracksApiClient.kt
new file mode 100644
index 00000000000..35bcc56b06d
--- /dev/null
+++ b/app/src/main/java/de/westnordost/streetcomplete/data/osmtracks/TracksApiClient.kt
@@ -0,0 +1,63 @@
+package de.westnordost.streetcomplete.data.osmtracks
+
+import de.westnordost.streetcomplete.ApplicationConstants
+import de.westnordost.streetcomplete.data.ConnectionException
+import de.westnordost.streetcomplete.data.AuthorizationException
+import de.westnordost.streetcomplete.data.user.UserLoginSource
+import de.westnordost.streetcomplete.data.wrapApiClientExceptions
+import de.westnordost.streetcomplete.util.ktx.truncate
+import io.ktor.client.HttpClient
+import io.ktor.client.call.body
+import io.ktor.client.plugins.expectSuccess
+import io.ktor.client.request.bearerAuth
+import io.ktor.client.request.forms.MultiPartFormDataContent
+import io.ktor.client.request.forms.formData
+import io.ktor.client.request.post
+import io.ktor.client.request.setBody
+import io.ktor.http.Headers
+import io.ktor.http.HttpHeaders
+import kotlinx.datetime.Instant
+
+/**
+ * Talks with OSM traces API to uploads GPS trackpoints
+ */
+class TracksApiClient(
+    private val httpClient: HttpClient,
+    private val baseUrl: String,
+    private val userLoginSource: UserLoginSource,
+    private val tracksSerializer: TracksSerializer
+) {
+    /**
+     * Upload a list of trackpoints as a GPX
+     *
+     * @param trackpoints recorded trackpoints
+     * @param noteText optional description text
+     *
+     * @throws AuthorizationException if not logged in or not not authorized to upload traces
+     *                                (scope "write_gpx")
+     * @throws ConnectionException if a temporary network connection problem occurs
+     *
+     * @return id of the uploaded track
+     */
+    suspend fun create(trackpoints: List<Trackpoint>, noteText: String? = null): Long = wrapApiClientExceptions {
+        val name = Instant.fromEpochMilliseconds(trackpoints.first().time).toString() + ".gpx"
+        val description = noteText ?: "Uploaded via ${ApplicationConstants.USER_AGENT}"
+        val tags = listOf(ApplicationConstants.NAME.lowercase()).joinToString()
+        val xml = tracksSerializer.serialize(trackpoints)
+
+        val response = httpClient.post(baseUrl + "gpx/create") {
+            userLoginSource.accessToken?.let { bearerAuth(it) }
+            setBody(MultiPartFormDataContent(formData {
+                append("file", xml, Headers.build {
+                    append(HttpHeaders.ContentType, "application/gpx+xml")
+                    append(HttpHeaders.ContentDisposition, "filename=\"$name\"")
+                })
+                append("description", description.truncate(255))
+                append("tags", tags)
+                append("visibility", "identifiable")
+            }))
+            expectSuccess = true
+        }
+        return response.body<String>().toLong()
+    }
+}
diff --git a/app/src/main/java/de/westnordost/streetcomplete/data/osmtracks/TracksApiImpl.kt b/app/src/main/java/de/westnordost/streetcomplete/data/osmtracks/TracksApiImpl.kt
deleted file mode 100644
index 3dfdde4ab4e..00000000000
--- a/app/src/main/java/de/westnordost/streetcomplete/data/osmtracks/TracksApiImpl.kt
+++ /dev/null
@@ -1,67 +0,0 @@
-package de.westnordost.streetcomplete.data.osmtracks
-
-import de.westnordost.osmapi.OsmConnection
-import de.westnordost.osmapi.common.errors.OsmApiException
-import de.westnordost.osmapi.common.errors.OsmApiReadResponseException
-import de.westnordost.osmapi.common.errors.OsmAuthorizationException
-import de.westnordost.osmapi.common.errors.OsmConnectionException
-import de.westnordost.osmapi.map.data.OsmLatLon
-import de.westnordost.osmapi.traces.GpsTraceDetails
-import de.westnordost.osmapi.traces.GpsTracesApi
-import de.westnordost.osmapi.traces.GpsTrackpoint
-import de.westnordost.streetcomplete.ApplicationConstants
-import de.westnordost.streetcomplete.data.AuthorizationException
-import de.westnordost.streetcomplete.data.ConnectionException
-import kotlinx.datetime.Instant
-import kotlinx.datetime.LocalDateTime
-import kotlinx.datetime.TimeZone
-import kotlinx.datetime.toJavaInstant
-import kotlinx.datetime.toLocalDateTime
-
-class TracksApiImpl(osm: OsmConnection) : TracksApi {
-    private val api: GpsTracesApi = GpsTracesApi(osm)
-
-    override fun create(trackpoints: List<Trackpoint>, noteText: String?): Long = wrapExceptions {
-        // Filename is just the start of the track
-        // https://stackoverflow.com/a/49862573/7718197
-        val name = Instant.fromEpochMilliseconds(trackpoints[0].time).toLocalDateTime(TimeZone.UTC).toTrackFilename()
-        val visibility = GpsTraceDetails.Visibility.IDENTIFIABLE
-        val description = noteText ?: "Uploaded via ${ApplicationConstants.USER_AGENT}"
-        val tags = listOf(ApplicationConstants.NAME.lowercase())
-
-        // Generate history of trackpoints
-        val history = trackpoints.mapIndexed { idx, it ->
-            GpsTrackpoint(
-                OsmLatLon(it.position.latitude, it.position.longitude),
-                Instant.fromEpochMilliseconds(it.time).toJavaInstant(),
-                idx == 0,
-                it.accuracy,
-                it.elevation
-            )
-        }
-
-        // Finally query the API and return!
-        api.create(name, visibility, description, tags, history)
-    }
-}
-
-private inline fun <T> wrapExceptions(block: () -> T): T =
-    try {
-        block()
-    } catch (e: OsmAuthorizationException) {
-        throw AuthorizationException(e.message, e)
-    } catch (e: OsmConnectionException) {
-        throw ConnectionException(e.message, e)
-    } catch (e: OsmApiReadResponseException) {
-        // probably a temporary connection error
-        throw ConnectionException(e.message, e)
-    } catch (e: OsmApiException) {
-        // request timeout is a temporary connection error
-        throw if (e.errorCode == 408) ConnectionException(e.message, e) else e
-    }
-
-private fun LocalDateTime.toTrackFilename(): String {
-    fun Int.f(len: Int): String = toString().padStart(len, '0')
-    return ("${year.f(4)}_${monthNumber.f(2)}_${dayOfMonth.f(2)}"
-        + "T${hour.f(2)}_${minute.f(2)}_${second.f(2)}.${nanosecond.f(6)}Z.gpx")
-}
diff --git a/app/src/main/java/de/westnordost/streetcomplete/data/osmtracks/TracksSerializer.kt b/app/src/main/java/de/westnordost/streetcomplete/data/osmtracks/TracksSerializer.kt
new file mode 100644
index 00000000000..46725e62e19
--- /dev/null
+++ b/app/src/main/java/de/westnordost/streetcomplete/data/osmtracks/TracksSerializer.kt
@@ -0,0 +1,45 @@
+package de.westnordost.streetcomplete.data.osmtracks
+
+import de.westnordost.streetcomplete.ApplicationConstants
+import de.westnordost.streetcomplete.util.ktx.attribute
+import de.westnordost.streetcomplete.util.ktx.endTag
+import de.westnordost.streetcomplete.util.ktx.startTag
+import kotlinx.datetime.Instant
+import nl.adaptivity.xmlutil.XmlWriter
+import nl.adaptivity.xmlutil.newWriter
+import nl.adaptivity.xmlutil.xmlStreaming
+
+class TracksSerializer {
+    fun serialize(trackpoints: List<Trackpoint>): String {
+        val buffer = StringBuilder()
+        xmlStreaming.newWriter(buffer).serializeToGpx(trackpoints)
+        return buffer.toString()
+    }
+}
+
+private fun XmlWriter.serializeToGpx(trackpoints: List<Trackpoint>) {
+    startTag("gpx")
+    attribute("xmlns", "http://www.topografix.com/GPX/1/0")
+    attribute("version", "1.0")
+    attribute("creator", ApplicationConstants.USER_AGENT)
+    startTag("trk")
+    startTag("trkseg")
+    for (trackpoint in trackpoints) {
+        startTag("trkpt")
+        attribute("lat", trackpoint.position.latitude.toString())
+        attribute("lon", trackpoint.position.longitude.toString())
+        startTag("time")
+        text(Instant.fromEpochMilliseconds(trackpoint.time).toString())
+        endTag("time")
+        startTag("ele")
+        text(trackpoint.elevation.toString())
+        endTag("ele")
+        startTag("hdop")
+        text(trackpoint.accuracy.toString())
+        endTag("hdop")
+        endTag("trkpt")
+    }
+    endTag("trkseg")
+    endTag("trk")
+    endTag("gpx")
+}
diff --git a/app/src/main/java/de/westnordost/streetcomplete/data/upload/Uploader.kt b/app/src/main/java/de/westnordost/streetcomplete/data/upload/Uploader.kt
index 8135a7cb0bd..707bdcfbcbb 100644
--- a/app/src/main/java/de/westnordost/streetcomplete/data/upload/Uploader.kt
+++ b/app/src/main/java/de/westnordost/streetcomplete/data/upload/Uploader.kt
@@ -54,7 +54,7 @@ class Uploader(
             listeners.forEach { it.onStarted() }
 
             if (!::bannedInfo.isInitialized) {
-                bannedInfo = withContext(Dispatchers.IO) { versionIsBannedChecker.get() }
+                bannedInfo = versionIsBannedChecker.get()
             }
             val banned = bannedInfo
             if (banned is IsBanned) {
diff --git a/app/src/main/java/de/westnordost/streetcomplete/data/user/UserApiClient.kt b/app/src/main/java/de/westnordost/streetcomplete/data/user/UserApiClient.kt
new file mode 100644
index 00000000000..10de4c73ac3
--- /dev/null
+++ b/app/src/main/java/de/westnordost/streetcomplete/data/user/UserApiClient.kt
@@ -0,0 +1,56 @@
+package de.westnordost.streetcomplete.data.user
+
+import de.westnordost.streetcomplete.data.AuthorizationException
+import de.westnordost.streetcomplete.data.ConnectionException
+import de.westnordost.streetcomplete.data.wrapApiClientExceptions
+import io.ktor.client.HttpClient
+import io.ktor.client.call.body
+import io.ktor.client.plugins.ClientRequestException
+import io.ktor.client.plugins.expectSuccess
+import io.ktor.client.request.bearerAuth
+import io.ktor.client.request.get
+import io.ktor.http.HttpStatusCode
+
+/**
+ * Talks with OSM user API
+ */
+class UserApiClient(
+    private val httpClient: HttpClient,
+    private val baseUrl: String,
+    private val userLoginSource: UserLoginSource,
+    private val userApiParser: UserApiParser,
+) {
+    /**
+     * @return the user info of the current user
+     *
+     * @throws AuthorizationException if we are not authorized to read user details (scope "read_prefs")
+     * @throws ConnectionException on connection or server error
+     */
+    suspend fun getMine(): UserInfo = wrapApiClientExceptions {
+        val response = httpClient.get(baseUrl + "user/details") {
+            userLoginSource.accessToken?.let { bearerAuth(it) }
+            expectSuccess = true
+        }
+        val body = response.body<String>()
+        return userApiParser.parseUsers(body).first()
+    }
+
+    /**
+     * @param userId id of the user to get the user info for
+     * @return the user info of the given user. Null if the user does not exist.
+     *
+     * @throws ConnectionException on connection or server error
+     */
+    suspend fun get(userId: Long): UserInfo? = wrapApiClientExceptions {
+        try {
+            val response = httpClient.get(baseUrl + "user/$userId") { expectSuccess = true }
+            val body = response.body<String>()
+            return userApiParser.parseUsers(body).first()
+        } catch (e: ClientRequestException) {
+            when (e.response.status) {
+                HttpStatusCode.Gone, HttpStatusCode.NotFound -> return null
+                else -> throw e
+            }
+        }
+    }
+}
diff --git a/app/src/main/java/de/westnordost/streetcomplete/data/user/UserApiParser.kt b/app/src/main/java/de/westnordost/streetcomplete/data/user/UserApiParser.kt
new file mode 100644
index 00000000000..d1e4171f09e
--- /dev/null
+++ b/app/src/main/java/de/westnordost/streetcomplete/data/user/UserApiParser.kt
@@ -0,0 +1,40 @@
+package de.westnordost.streetcomplete.data.user
+
+import de.westnordost.streetcomplete.util.ktx.attribute
+import kotlinx.serialization.SerializationException
+import nl.adaptivity.xmlutil.EventType.*
+import nl.adaptivity.xmlutil.XmlReader
+import nl.adaptivity.xmlutil.xmlStreaming
+
+class UserApiParser {
+    fun parseUsers(osmXml: String): List<UserInfo> =
+        xmlStreaming.newReader(osmXml).parseUsers()
+}
+
+private fun XmlReader.parseUsers(): List<UserInfo> = try {
+    val result = ArrayList<UserInfo>(1)
+    var id: Long? = null
+    var displayName: String? = null
+    var img: String? = null
+    var unread: Int? = null
+    var isMessages = false
+    forEach { when (it) {
+        START_ELEMENT -> when (localName) {
+            "user" -> {
+                id = attribute("id").toLong()
+                displayName = attribute("display_name")
+                img = null
+                unread = null
+            }
+            "img" -> img = attribute("href")
+            "messages" -> isMessages = true
+            "received" -> if (isMessages) unread = attribute("unread").toInt()
+        }
+        END_ELEMENT -> when (localName) {
+            "user" -> result.add(UserInfo(id!!, displayName!!, img, unread))
+            "messages" -> isMessages = false
+        }
+        else -> {}
+    } }
+    result
+} catch (e: Exception) { throw SerializationException(e) }
diff --git a/app/src/main/java/de/westnordost/streetcomplete/data/user/UserDataController.kt b/app/src/main/java/de/westnordost/streetcomplete/data/user/UserDataController.kt
index dc32c54abad..e1feb20a54b 100644
--- a/app/src/main/java/de/westnordost/streetcomplete/data/user/UserDataController.kt
+++ b/app/src/main/java/de/westnordost/streetcomplete/data/user/UserDataController.kt
@@ -1,6 +1,5 @@
 package de.westnordost.streetcomplete.data.user
 
-import de.westnordost.osmapi.user.UserDetails
 import de.westnordost.streetcomplete.data.preferences.Preferences
 import de.westnordost.streetcomplete.util.Listeners
 
@@ -19,10 +18,10 @@ class UserDataController(private val prefs: Preferences) : UserDataSource {
             listeners.forEach { it.onUpdated() }
         }
 
-    fun setDetails(userDetails: UserDetails) {
+    fun setDetails(userDetails: UserInfo) {
         prefs.userId = userDetails.id
         prefs.userName = userDetails.displayName
-        prefs.userUnreadMessages = userDetails.unreadMessagesCount
+        userDetails.unreadMessagesCount?.let { prefs.userUnreadMessages }
         listeners.forEach { it.onUpdated() }
     }
 
diff --git a/app/src/main/java/de/westnordost/streetcomplete/data/user/UserInfo.kt b/app/src/main/java/de/westnordost/streetcomplete/data/user/UserInfo.kt
new file mode 100644
index 00000000000..bff4800266d
--- /dev/null
+++ b/app/src/main/java/de/westnordost/streetcomplete/data/user/UserInfo.kt
@@ -0,0 +1,8 @@
+package de.westnordost.streetcomplete.data.user
+
+data class UserInfo(
+    val id: Long,
+    val displayName: String,
+    val profileImageUrl: String?,
+    val unreadMessagesCount: Int? = null,
+)
diff --git a/app/src/main/java/de/westnordost/streetcomplete/data/user/UserLoginController.kt b/app/src/main/java/de/westnordost/streetcomplete/data/user/UserLoginController.kt
index fe0f8b7e974..20ec2c62e88 100644
--- a/app/src/main/java/de/westnordost/streetcomplete/data/user/UserLoginController.kt
+++ b/app/src/main/java/de/westnordost/streetcomplete/data/user/UserLoginController.kt
@@ -1,11 +1,9 @@
 package de.westnordost.streetcomplete.data.user
 
-import de.westnordost.osmapi.OsmConnection
 import de.westnordost.streetcomplete.data.preferences.Preferences
 import de.westnordost.streetcomplete.util.Listeners
 
 class UserLoginController(
-    private val osmConnection: OsmConnection,
     private val prefs: Preferences,
 ) : UserLoginSource {
 
@@ -18,14 +16,12 @@ class UserLoginController(
 
     fun logIn(accessToken: String) {
         prefs.oAuth2AccessToken = accessToken
-        osmConnection.oAuthAccessToken = accessToken
         listeners.forEach { it.onLoggedIn() }
     }
 
     fun logOut() {
         prefs.oAuth2AccessToken = null
         prefs.removeOAuth1Data()
-        osmConnection.oAuthAccessToken = null
         listeners.forEach { it.onLoggedOut() }
     }
 
diff --git a/app/src/main/java/de/westnordost/streetcomplete/data/user/UserModule.kt b/app/src/main/java/de/westnordost/streetcomplete/data/user/UserModule.kt
index 29ee845fc71..018904676ad 100644
--- a/app/src/main/java/de/westnordost/streetcomplete/data/user/UserModule.kt
+++ b/app/src/main/java/de/westnordost/streetcomplete/data/user/UserModule.kt
@@ -1,6 +1,6 @@
 package de.westnordost.streetcomplete.data.user
 
-import de.westnordost.streetcomplete.data.user.oauth.OAuthService
+import de.westnordost.streetcomplete.data.user.oauth.OAuthApiClient
 import org.koin.dsl.module
 
 const val OAUTH2_TOKEN_URL = "https://www.openstreetmap.org/oauth2/token"
@@ -40,9 +40,9 @@ val userModule = module {
     single { UserDataController(get()) }
 
     single<UserLoginSource> { get<UserLoginController>() }
-    single { UserLoginController(get(), get()) }
+    single { UserLoginController(get()) }
 
     single { UserUpdater(get(), get(), get(), get(), get(), get()) }
 
-    single { OAuthService(get()) }
+    single { OAuthApiClient(get()) }
 }
diff --git a/app/src/main/java/de/westnordost/streetcomplete/data/user/UserUpdater.kt b/app/src/main/java/de/westnordost/streetcomplete/data/user/UserUpdater.kt
index 4162bc62f23..b721bad3331 100644
--- a/app/src/main/java/de/westnordost/streetcomplete/data/user/UserUpdater.kt
+++ b/app/src/main/java/de/westnordost/streetcomplete/data/user/UserUpdater.kt
@@ -1,9 +1,8 @@
 package de.westnordost.streetcomplete.data.user
 
-import de.westnordost.osmapi.user.UserApi
 import de.westnordost.streetcomplete.data.osmnotes.AvatarsDownloader
 import de.westnordost.streetcomplete.data.user.statistics.StatisticsController
-import de.westnordost.streetcomplete.data.user.statistics.StatisticsDownloader
+import de.westnordost.streetcomplete.data.user.statistics.StatisticsApiClient
 import de.westnordost.streetcomplete.util.Listeners
 import de.westnordost.streetcomplete.util.logs.Log
 import kotlinx.coroutines.CoroutineScope
@@ -12,9 +11,9 @@ import kotlinx.coroutines.SupervisorJob
 import kotlinx.coroutines.launch
 
 class UserUpdater(
-    private val userApi: UserApi,
+    private val userApi: UserApiClient,
     private val avatarsDownloader: AvatarsDownloader,
-    private val statisticsDownloader: StatisticsDownloader,
+    private val statisticsApiClient: StatisticsApiClient,
     private val userDataController: UserDataController,
     private val statisticsController: StatisticsController,
     private val userLoginSource: UserLoginSource
@@ -67,7 +66,7 @@ class UserUpdater(
 
     private fun updateStatistics(userId: Long) = coroutineScope.launch(Dispatchers.IO) {
         try {
-            val statistics = statisticsDownloader.download(userId)
+            val statistics = statisticsApiClient.get(userId)
             statisticsController.updateAll(statistics)
         } catch (e: Exception) {
             Log.w(TAG, "Unable to download statistics", e)
diff --git a/app/src/main/java/de/westnordost/streetcomplete/data/user/oauth/OAuthService.kt b/app/src/main/java/de/westnordost/streetcomplete/data/user/oauth/OAuthApiClient.kt
similarity index 76%
rename from app/src/main/java/de/westnordost/streetcomplete/data/user/oauth/OAuthService.kt
rename to app/src/main/java/de/westnordost/streetcomplete/data/user/oauth/OAuthApiClient.kt
index 91535d92285..76945762a9c 100644
--- a/app/src/main/java/de/westnordost/streetcomplete/data/user/oauth/OAuthService.kt
+++ b/app/src/main/java/de/westnordost/streetcomplete/data/user/oauth/OAuthApiClient.kt
@@ -1,7 +1,12 @@
 package de.westnordost.streetcomplete.data.user.oauth
 
+import de.westnordost.streetcomplete.data.ConnectionException
+import de.westnordost.streetcomplete.data.wrapApiClientExceptions
 import io.ktor.client.HttpClient
 import io.ktor.client.call.body
+import io.ktor.client.plugins.ClientRequestException
+import io.ktor.client.plugins.expectSuccess
+import io.ktor.client.request.parameter
 import io.ktor.client.request.post
 import io.ktor.http.ContentType
 import io.ktor.http.HttpStatusCode
@@ -10,6 +15,8 @@ import io.ktor.http.URLParserException
 import io.ktor.http.Url
 import io.ktor.http.contentType
 import io.ktor.http.decodeURLQueryComponent
+import io.ktor.http.isSuccess
+import io.ktor.http.parameters
 import io.ktor.http.takeFrom
 import io.ktor.utils.io.errors.IOException
 import kotlinx.serialization.Serializable
@@ -21,6 +28,8 @@ import kotlin.io.encoding.Base64
 import kotlin.io.encoding.ExperimentalEncodingApi
 
 /**
+ * Client to get the access token as the third and final step of the OAuth authorization flow.
+ *
  * Authorization flow:
  *
  * 1. Generate and store a OAuthAuthorizationParams instance and open the authorizationRequestUrl
@@ -30,50 +39,42 @@ import kotlin.io.encoding.ExperimentalEncodingApi
  * 3. Check if the URI received is matches the OAuthAuthorizationParams instance with itsForMe(uri)
  *    and feed the received uri to OAuthService.retrieveAccessToken(uri)
  */
-class OAuthService(private val httpClient: HttpClient) {
+class OAuthApiClient(private val httpClient: HttpClient) {
     private val json = Json { ignoreUnknownKeys = true }
 
     /**
      * Retrieves the access token, given the [authorizationResponseUrl]
      *
      * @throws OAuthException if there has been an OAuth authorization error
-     * @throws OAuthConnectionException if the server reply is malformed or there is an issue with
-     *                                   the connection
+     * @throws ConnectionException if the server reply is malformed or there is an issue with
+     *                             the connection
      */
-    suspend fun retrieveAccessToken(
+    suspend fun getAccessToken(
         request: OAuthAuthorizationParams,
         authorizationResponseUrl: String
-    ): AccessTokenResponse {
-        val authorizationCode = extractAuthorizationCode(authorizationResponseUrl)
+    ): AccessTokenResponse = wrapApiClientExceptions {
         try {
             val response = httpClient.post(request.accessTokenUrl) {
-                url {
-                    parameters.append("grant_type", "authorization_code")
-                    parameters.append("client_id", request.clientId)
-                    parameters.append("code", authorizationCode)
-                    parameters.append("redirect_uri", request.redirectUri)
-                    parameters.append("code_verifier", request.codeVerifier)
-                }
                 contentType(ContentType.Application.FormUrlEncoded)
+                parameter("grant_type", "authorization_code")
+                parameter("client_id", request.clientId)
+                parameter("code", extractAuthorizationCode(authorizationResponseUrl))
+                parameter("redirect_uri", request.redirectUri)
+                parameter("code_verifier", request.codeVerifier)
+                expectSuccess = true
             }
-
-            if (response.status != HttpStatusCode.OK) {
-                val errorResponse = json.decodeFromString<ErrorResponseJson>(response.body())
+            val accessTokenResponse = json.decodeFromString<AccessTokenResponseJson>(response.body())
+            if (accessTokenResponse.token_type.lowercase() != "bearer") {
+                throw ConnectionException("OAuth 2 token endpoint returned an unknown token type (${accessTokenResponse.token_type})")
+            }
+            return AccessTokenResponse(accessTokenResponse.access_token, accessTokenResponse.scope?.split(" "))
+        } catch (e: ClientRequestException) {
+            if (e.response.status == HttpStatusCode.BadRequest) {
+                val errorResponse = json.decodeFromString<ErrorResponseJson>(e.response.body())
                 throw OAuthException(errorResponse.error, errorResponse.error_description, errorResponse.error_uri)
             } else {
-                val accessTokenResponse = json.decodeFromString<AccessTokenResponseJson>(response.body())
-                if (accessTokenResponse.token_type.lowercase() != "bearer") {
-                    throw OAuthConnectionException("OAuth 2 token endpoint returned an unknown token type (${accessTokenResponse.token_type})")
-                }
-                return AccessTokenResponse(accessTokenResponse.access_token, accessTokenResponse.scope?.split(" "))
+                throw e
             }
-            // if OSM server does not return valid JSON, it is the server's fault, hence
-        } catch (e: SerializationException) {
-            throw OAuthConnectionException("OAuth 2 token endpoint did not return a valid response", e)
-        } catch (e: IllegalArgumentException) {
-            throw OAuthConnectionException("OAuth 2 token endpoint did not return a valid response", e)
-        } catch (e: IOException) {
-            throw OAuthConnectionException(cause = e)
         }
     }
 }
@@ -145,7 +146,7 @@ class OAuthService(private val httpClient: HttpClient) {
  *
  * @throws OAuthException if the URI does not contain the authorization code, e.g.
  *                        the user did not accept the requested permissions
- * @throws OAuthConnectionException if the server reply is malformed
+ * @throws ConnectionException if the server reply is malformed
  */
 private fun extractAuthorizationCode(uri: String): String {
     val parameters = Url(uri).parameters
@@ -153,7 +154,7 @@ private fun extractAuthorizationCode(uri: String): String {
     if (authorizationCode != null) return authorizationCode
 
     val error = parameters["error"]
-        ?: throw OAuthConnectionException("OAuth 2 authorization endpoint did not return a valid error response: $uri")
+        ?: throw ConnectionException("OAuth 2 authorization endpoint did not return a valid error response: $uri")
 
     throw OAuthException(
         error.decodeURLQueryComponent(plusIsSpace = true),
diff --git a/app/src/main/java/de/westnordost/streetcomplete/data/user/oauth/OAuthConnectionException.kt b/app/src/main/java/de/westnordost/streetcomplete/data/user/oauth/OAuthConnectionException.kt
deleted file mode 100644
index 4bfebe7c341..00000000000
--- a/app/src/main/java/de/westnordost/streetcomplete/data/user/oauth/OAuthConnectionException.kt
+++ /dev/null
@@ -1,7 +0,0 @@
-package de.westnordost.streetcomplete.data.user.oauth
-
-/** OAuth failed due to an issue with the connection or a malformed server response */
-class OAuthConnectionException @JvmOverloads constructor(
-    message: String? = null,
-    cause: Throwable? = null
-) : RuntimeException(message, cause)
diff --git a/app/src/main/java/de/westnordost/streetcomplete/data/user/statistics/StatisticsDownloader.kt b/app/src/main/java/de/westnordost/streetcomplete/data/user/statistics/StatisticsApiClient.kt
similarity index 51%
rename from app/src/main/java/de/westnordost/streetcomplete/data/user/statistics/StatisticsDownloader.kt
rename to app/src/main/java/de/westnordost/streetcomplete/data/user/statistics/StatisticsApiClient.kt
index d4ff206b19c..6ae2657fd93 100644
--- a/app/src/main/java/de/westnordost/streetcomplete/data/user/statistics/StatisticsDownloader.kt
+++ b/app/src/main/java/de/westnordost/streetcomplete/data/user/statistics/StatisticsApiClient.kt
@@ -1,17 +1,23 @@
 package de.westnordost.streetcomplete.data.user.statistics
 
+import de.westnordost.streetcomplete.data.ConnectionException
+import de.westnordost.streetcomplete.data.wrapApiClientExceptions
 import io.ktor.client.HttpClient
 import io.ktor.client.call.body
 import io.ktor.client.plugins.expectSuccess
 import io.ktor.client.request.get
 
-/** Downloads statistics from the backend */
-class StatisticsDownloader(
+/** Client for the statistics service
+ *  https://github.com/streetcomplete/sc-statistics-service/ */
+class StatisticsApiClient(
     private val httpClient: HttpClient,
     private val baseUrl: String,
     private val statisticsParser: StatisticsParser
 ) {
-    suspend fun download(osmUserId: Long): Statistics {
+    /** Get the statistics for the given user id
+     *
+     * @throws ConnectionException on connection or server error */
+    suspend fun get(osmUserId: Long): Statistics = wrapApiClientExceptions {
         val response = httpClient.get("$baseUrl?user_id=$osmUserId") { expectSuccess = true }
         return statisticsParser.parse(response.body())
     }
diff --git a/app/src/main/java/de/westnordost/streetcomplete/data/user/statistics/StatisticsModule.kt b/app/src/main/java/de/westnordost/streetcomplete/data/user/statistics/StatisticsModule.kt
index feb6b3880ea..c715dfb14b4 100644
--- a/app/src/main/java/de/westnordost/streetcomplete/data/user/statistics/StatisticsModule.kt
+++ b/app/src/main/java/de/westnordost/streetcomplete/data/user/statistics/StatisticsModule.kt
@@ -14,7 +14,7 @@ val statisticsModule = module {
 
     factory { ActiveDatesDao(get()) }
 
-    factory { StatisticsDownloader(get(), STATISTICS_BACKEND_URL, get()) }
+    factory { StatisticsApiClient(get(), STATISTICS_BACKEND_URL, get()) }
     factory { StatisticsParser(get(named("TypeAliases"))) }
 
     single<StatisticsSource> { get<StatisticsController>() }
diff --git a/app/src/main/java/de/westnordost/streetcomplete/data/user/statistics/StatisticsParser.kt b/app/src/main/java/de/westnordost/streetcomplete/data/user/statistics/StatisticsParser.kt
index 1bf5f687580..30466407419 100644
--- a/app/src/main/java/de/westnordost/streetcomplete/data/user/statistics/StatisticsParser.kt
+++ b/app/src/main/java/de/westnordost/streetcomplete/data/user/statistics/StatisticsParser.kt
@@ -3,46 +3,33 @@ package de.westnordost.streetcomplete.data.user.statistics
 import kotlinx.datetime.Instant
 import kotlinx.datetime.LocalDate
 import kotlinx.serialization.Serializable
-import kotlinx.serialization.json.Json.Default.decodeFromString
-
-@Serializable
-private data class StatisticsDTO(
-    val questTypes: Map<String, Int>,
-    val countries: Map<String, Int>,
-    val countryRanks: Map<String, Int>,
-    val rank: Int,
-    val currentWeekRank: Int,
-    val currentWeekQuestTypes: Map<String, Int>,
-    val currentWeekCountries: Map<String, Int>,
-    val currentWeekCountryRanks: Map<String, Int>,
-    val daysActive: Int,
-    val activeDatesRange: Int,
-    val activeDates: List<LocalDate>,
-    val lastUpdate: Instant,
-    val isAnalyzing: Boolean,
-)
+import kotlinx.serialization.json.Json
 
 class StatisticsParser(private val typeAliases: List<Pair<String, String>>) {
-    fun parse(json: String): Statistics =
-        with(decodeFromString<StatisticsDTO>(json)) {
-            Statistics(
-                types = parseEditTypeStatistics(questTypes),
-                countries = countries.map { (key, value) ->
-                    CountryStatistics(countryCode = key, count = value, rank = countryRanks[key])
-                }.sortedBy(CountryStatistics::countryCode),
-                rank = rank,
-                daysActive = daysActive,
-                currentWeekRank = currentWeekRank,
-                currentWeekTypes = parseEditTypeStatistics(currentWeekQuestTypes),
-                currentWeekCountries = currentWeekCountries.map { (key, value) ->
-                    CountryStatistics(countryCode = key, count = value, rank = currentWeekCountryRanks[key])
-                }.sortedBy(CountryStatistics::countryCode),
-                activeDatesRange = activeDatesRange,
-                activeDates = activeDates,
-                lastUpdate = lastUpdate.toEpochMilliseconds(),
-                isAnalyzing = isAnalyzing,
-            )
-        }
+    private val jsonParser = Json { ignoreUnknownKeys = true }
+
+    fun parse(json: String): Statistics {
+        val apiStatistics = jsonParser.decodeFromString<ApiStatistics>(json)
+        return apiStatistics.toStatistics()
+    }
+
+    private fun ApiStatistics.toStatistics() = Statistics(
+        types = parseEditTypeStatistics(questTypes),
+        countries = countries.map { (key, value) ->
+            CountryStatistics(countryCode = key, count = value, rank = countryRanks[key])
+        }.sortedBy(CountryStatistics::countryCode),
+        rank = rank,
+        daysActive = daysActive,
+        currentWeekRank = currentWeekRank,
+        currentWeekTypes = parseEditTypeStatistics(currentWeekQuestTypes),
+        currentWeekCountries = currentWeekCountries.map { (key, value) ->
+            CountryStatistics(countryCode = key, count = value, rank = currentWeekCountryRanks[key])
+        }.sortedBy(CountryStatistics::countryCode),
+        activeDatesRange = activeDatesRange,
+        activeDates = activeDates,
+        lastUpdate = lastUpdate.toEpochMilliseconds(),
+        isAnalyzing = isAnalyzing,
+    )
 
     private fun parseEditTypeStatistics(input: Map<String, Int>): List<EditTypeStatistics> {
         val result = input.toMutableMap()
@@ -60,3 +47,20 @@ class StatisticsParser(private val typeAliases: List<Pair<String, String>>) {
         }
     }
 }
+
+@Serializable
+private data class ApiStatistics(
+    val questTypes: Map<String, Int>,
+    val countries: Map<String, Int>,
+    val countryRanks: Map<String, Int>,
+    val rank: Int,
+    val currentWeekRank: Int,
+    val currentWeekQuestTypes: Map<String, Int>,
+    val currentWeekCountries: Map<String, Int>,
+    val currentWeekCountryRanks: Map<String, Int>,
+    val daysActive: Int,
+    val activeDatesRange: Int,
+    val activeDates: List<LocalDate>,
+    val lastUpdate: Instant,
+    val isAnalyzing: Boolean,
+)
diff --git a/app/src/main/java/de/westnordost/streetcomplete/screens/MainActivity.kt b/app/src/main/java/de/westnordost/streetcomplete/screens/MainActivity.kt
index 4b84974e3f0..a92f1c373dd 100644
--- a/app/src/main/java/de/westnordost/streetcomplete/screens/MainActivity.kt
+++ b/app/src/main/java/de/westnordost/streetcomplete/screens/MainActivity.kt
@@ -28,7 +28,6 @@ import de.westnordost.streetcomplete.data.messages.Message
 import de.westnordost.streetcomplete.data.osm.edits.ElementEdit
 import de.westnordost.streetcomplete.data.osm.edits.ElementEditsSource
 import de.westnordost.streetcomplete.data.osm.mapdata.LatLon
-import de.westnordost.streetcomplete.data.osmnotes.ImageUploadServerException
 import de.westnordost.streetcomplete.data.osmnotes.edits.NoteEdit
 import de.westnordost.streetcomplete.data.osmnotes.edits.NoteEditsSource
 import de.westnordost.streetcomplete.data.preferences.Preferences
@@ -240,7 +239,7 @@ class MainActivity :
                         messageView.movementMethod = LinkMovementMethod.getInstance()
                         Linkify.addLinks(messageView, Linkify.WEB_URLS)
                     }
-                } else if (e is ConnectionException || e is ImageUploadServerException) {
+                } else if (e is ConnectionException) {
                     /* A network connection error or server error is not the fault of this app.
                        Nothing we can do about it, so it does not make sense to send an error
                        report. Just notify the user. */
diff --git a/app/src/main/java/de/westnordost/streetcomplete/screens/user/login/LoginViewModel.kt b/app/src/main/java/de/westnordost/streetcomplete/screens/user/login/LoginViewModel.kt
index 7f60458777e..84d91574286 100644
--- a/app/src/main/java/de/westnordost/streetcomplete/screens/user/login/LoginViewModel.kt
+++ b/app/src/main/java/de/westnordost/streetcomplete/screens/user/login/LoginViewModel.kt
@@ -11,7 +11,7 @@ import de.westnordost.streetcomplete.data.user.OAUTH2_TOKEN_URL
 import de.westnordost.streetcomplete.data.user.UserLoginController
 import de.westnordost.streetcomplete.data.user.oauth.OAuthAuthorizationParams
 import de.westnordost.streetcomplete.data.user.oauth.OAuthException
-import de.westnordost.streetcomplete.data.user.oauth.OAuthService
+import de.westnordost.streetcomplete.data.user.oauth.OAuthApiClient
 import de.westnordost.streetcomplete.util.ktx.launch
 import de.westnordost.streetcomplete.util.logs.Log
 import kotlinx.coroutines.Dispatchers
@@ -56,7 +56,7 @@ data object LoggedIn : LoginState
 class LoginViewModelImpl(
     private val unsyncedChangesCountSource: UnsyncedChangesCountSource,
     private val userLoginController: UserLoginController,
-    private val oAuthService: OAuthService
+    private val oAuthApiClient: OAuthApiClient
 ) : LoginViewModel() {
     override val loginState = MutableStateFlow<LoginState>(LoggedOut)
     override val unsyncedChangesCount = MutableStateFlow(0)
@@ -101,9 +101,7 @@ class LoginViewModelImpl(
     private suspend fun retrieveAccessToken(authorizationResponseUrl: String): String? {
         try {
             loginState.value = RetrievingAccessToken
-            val accessTokenResponse = withContext(Dispatchers.IO) {
-                oAuthService.retrieveAccessToken(oAuth, authorizationResponseUrl)
-            }
+            val accessTokenResponse = oAuthApiClient.getAccessToken(oAuth, authorizationResponseUrl)
             if (accessTokenResponse.grantedScopes?.containsAll(OAUTH2_REQUIRED_SCOPES) == false) {
                 loginState.value = LoginError.RequiredPermissionsNotGranted
                 return null
diff --git a/app/src/main/java/de/westnordost/streetcomplete/util/ktx/XmlReader.kt b/app/src/main/java/de/westnordost/streetcomplete/util/ktx/XmlReader.kt
new file mode 100644
index 00000000000..cf85d795deb
--- /dev/null
+++ b/app/src/main/java/de/westnordost/streetcomplete/util/ktx/XmlReader.kt
@@ -0,0 +1,7 @@
+package de.westnordost.streetcomplete.util.ktx
+
+import nl.adaptivity.xmlutil.XmlReader
+
+fun XmlReader.attribute(name: String): String = getAttributeValue(null, name)!!
+
+fun XmlReader.attributeOrNull(name: String): String? = getAttributeValue(null, name)
diff --git a/app/src/main/java/de/westnordost/streetcomplete/util/ktx/XmlWriter.kt b/app/src/main/java/de/westnordost/streetcomplete/util/ktx/XmlWriter.kt
new file mode 100644
index 00000000000..6254364a5c3
--- /dev/null
+++ b/app/src/main/java/de/westnordost/streetcomplete/util/ktx/XmlWriter.kt
@@ -0,0 +1,9 @@
+package de.westnordost.streetcomplete.util.ktx
+
+import nl.adaptivity.xmlutil.XmlWriter
+
+fun XmlWriter.startTag(name: String) = startTag("", name, null)
+
+fun XmlWriter.endTag(name: String) = endTag("", name, null)
+
+fun XmlWriter.attribute(name: String, value: String) = attribute(null, name, null, value)


Test patch:
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 1afbc4b55c8..c557f377339 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,13 +4,14 @@ 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.MapDataApi
+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.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
@@ -21,7 +22,7 @@ import kotlin.test.assertFailsWith
 class ElementEditUploaderTest {
 
     private lateinit var changesetManager: OpenChangesetsManager
-    private lateinit var mapDataApi: MapDataApi
+    private lateinit var mapDataApi: MapDataApiClient
     private lateinit var mapDataController: MapDataController
     private lateinit var uploader: ElementEditUploader
 
@@ -33,21 +34,7 @@ class ElementEditUploaderTest {
         uploader = ElementEditUploader(changesetManager, mapDataApi, mapDataController)
     }
 
-    @Test
-    fun `passes on conflict exception`() {
-        val edit: ElementEdit = mock()
-        val action: ElementEditAction = mock()
-        on(edit.action).thenReturn(action)
-        on(action.createUpdates(any(), any())).thenReturn(MapDataChanges())
-        on(mapDataApi.uploadChanges(anyLong(), any(), any())).thenThrow(ConflictException())
-
-        assertFailsWith<ConflictException> {
-            uploader.upload(edit, { mock() })
-        }
-    }
-
-    @Test
-    fun `passes on element conflict exception`() {
+    @Test fun `passes on conflict exception`(): Unit = runBlocking {
         val edit: ElementEdit = mock()
         val action: ElementEditAction = mock()
         on(edit.action).thenReturn(action)
@@ -55,15 +42,15 @@ class ElementEditUploaderTest {
 
         on(changesetManager.getOrCreateChangeset(any(), any(), any(), anyBoolean())).thenReturn(1)
         on(changesetManager.createChangeset(any(), any(), any())).thenReturn(1)
-        on(mapDataApi.uploadChanges(anyLong(), any(), any()))
-            .thenThrow(ConflictException())
+        on(mapDataApi.uploadChanges(anyLong(), any(), any())).thenThrow(ConflictException())
 
         assertFailsWith<ConflictException> {
             uploader.upload(edit, { mock() })
         }
     }
 
-    @Test fun `handles changeset conflict exception`() {
+
+    @Test fun `handles changeset conflict exception`(): Unit = runBlocking {
         val edit: ElementEdit = mock()
         val action: ElementEditAction = mock()
         on(edit.action).thenReturn(action)
diff --git a/app/src/test/java/de/westnordost/streetcomplete/data/osm/edits/upload/ElementEditsUploaderTest.kt b/app/src/test/java/de/westnordost/streetcomplete/data/osm/edits/upload/ElementEditsUploaderTest.kt
index 9650f75a2f7..38a45ea06ed 100644
--- a/app/src/test/java/de/westnordost/streetcomplete/data/osm/edits/upload/ElementEditsUploaderTest.kt
+++ b/app/src/test/java/de/westnordost/streetcomplete/data/osm/edits/upload/ElementEditsUploaderTest.kt
@@ -5,7 +5,7 @@ import de.westnordost.streetcomplete.data.osm.edits.ElementEditAction
 import de.westnordost.streetcomplete.data.osm.edits.ElementEditsController
 import de.westnordost.streetcomplete.data.osm.mapdata.ElementKey
 import de.westnordost.streetcomplete.data.osm.mapdata.ElementType
-import de.westnordost.streetcomplete.data.osm.mapdata.MapDataApi
+import de.westnordost.streetcomplete.data.osm.mapdata.MapDataApiClient
 import de.westnordost.streetcomplete.data.osm.mapdata.MapDataController
 import de.westnordost.streetcomplete.data.osm.mapdata.MapDataUpdates
 import de.westnordost.streetcomplete.data.osmnotes.edits.NoteEditsController
@@ -31,7 +31,7 @@ class ElementEditsUploaderTest {
     private lateinit var mapDataController: MapDataController
     private lateinit var noteEditsController: NoteEditsController
     private lateinit var singleUploader: ElementEditUploader
-    private lateinit var mapDataApi: MapDataApi
+    private lateinit var mapDataApi: MapDataApiClient
     private lateinit var statisticsController: StatisticsController
 
     private lateinit var uploader: ElementEditsUploader
diff --git a/app/src/test/java/de/westnordost/streetcomplete/data/osm/edits/upload/MapDataUpdatesTest.kt b/app/src/test/java/de/westnordost/streetcomplete/data/osm/edits/upload/MapDataUpdatesTest.kt
new file mode 100644
index 00000000000..8f52ab22bc0
--- /dev/null
+++ b/app/src/test/java/de/westnordost/streetcomplete/data/osm/edits/upload/MapDataUpdatesTest.kt
@@ -0,0 +1,207 @@
+package de.westnordost.streetcomplete.data.osm.edits.upload
+
+import de.westnordost.streetcomplete.data.osm.mapdata.DeleteElement
+import de.westnordost.streetcomplete.data.osm.mapdata.ElementIdUpdate
+import de.westnordost.streetcomplete.data.osm.mapdata.ElementKey
+import de.westnordost.streetcomplete.data.osm.mapdata.ElementType.NODE
+import de.westnordost.streetcomplete.data.osm.mapdata.ElementType.RELATION
+import de.westnordost.streetcomplete.data.osm.mapdata.ElementType.WAY
+import de.westnordost.streetcomplete.data.osm.mapdata.Relation
+import de.westnordost.streetcomplete.data.osm.mapdata.UpdateElement
+import de.westnordost.streetcomplete.data.osm.mapdata.Way
+import de.westnordost.streetcomplete.data.osm.mapdata.createMapDataUpdates
+import de.westnordost.streetcomplete.data.osm.mapdata.key
+import de.westnordost.streetcomplete.testutils.member
+import de.westnordost.streetcomplete.testutils.node
+import de.westnordost.streetcomplete.testutils.rel
+import de.westnordost.streetcomplete.testutils.way
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertTrue
+
+class MapDataUpdatesTest {
+        @Test fun `updates element version`() {
+        val updates = createMapDataUpdates(
+            elements = listOf(node(1), way(2), rel(3)),
+            updates = mapOf(
+                ElementKey(NODE, 1) to UpdateElement(1L, 123),
+                ElementKey(WAY, 2) to UpdateElement(2L, 124),
+                ElementKey(RELATION, 3) to UpdateElement(3L, 125),
+            )
+        )
+
+        val elements = updates.updated.associateBy { it.key }
+        assertEquals(123, elements[ElementKey(NODE, 1)]?.version)
+        assertEquals(124, elements[ElementKey(WAY, 2)]?.version)
+        assertEquals(125, elements[ElementKey(RELATION, 3)]?.version)
+        assertTrue(updates.deleted.isEmpty())
+        assertTrue(updates.idUpdates.isEmpty())
+    }
+
+    @Test fun `deletes element`() {
+        val updates = createMapDataUpdates(
+            elements = listOf(node(1), way(2), rel(3)),
+            updates = mapOf(
+                ElementKey(NODE, 1) to DeleteElement,
+                ElementKey(WAY, 2) to DeleteElement,
+                ElementKey(RELATION, 3) to DeleteElement,
+            )
+        )
+
+        assertTrue(updates.updated.isEmpty())
+        assertTrue(updates.idUpdates.isEmpty())
+        assertEquals(
+            setOf(
+                ElementKey(NODE, 1),
+                ElementKey(WAY, 2),
+                ElementKey(RELATION, 3)
+            ),
+            updates.deleted.toSet()
+        )
+    }
+
+    @Test fun `updates element id`() {
+        val updates = createMapDataUpdates(
+            elements = listOf(node(1), way(2), rel(3)),
+            updates = mapOf(
+                ElementKey(NODE, 1) to UpdateElement(12L, 1),
+                ElementKey(WAY, 2) to UpdateElement(22L, 1),
+                ElementKey(RELATION, 3) to UpdateElement(32L, 1),
+            )
+        )
+
+        assertEquals(
+            setOf(
+                ElementKey(NODE, 12),
+                ElementKey(WAY, 22),
+                ElementKey(RELATION, 32)
+            ),
+            updates.updated.mapTo(HashSet()) { it.key }
+        )
+
+        assertTrue(updates.deleted.isEmpty())
+        assertEquals(
+            setOf(
+                ElementIdUpdate(NODE, 1, 12),
+                ElementIdUpdate(WAY, 2, 22),
+                ElementIdUpdate(RELATION, 3, 32)
+            ),
+            updates.idUpdates.toSet()
+        )
+    }
+
+    @Test fun `updates node id and all ways containing this id`() {
+        val updates = createMapDataUpdates(
+            elements = listOf(
+                node(-1),
+                way(1, listOf(3, 2, -1)), // contains it once
+                way(2, listOf(-1, 2, -1, -1)), // contains it multiple times
+                way(3, listOf(3, 4)) // contains it not
+            ),
+            updates = mapOf(ElementKey(NODE, -1) to UpdateElement(1L, 1),)
+        )
+
+        val ways = updates.updated.filterIsInstance<Way>().associateBy { it.id }
+        assertEquals(2, ways.size)
+        assertEquals(listOf<Long>(3, 2, 1), ways[1]?.nodeIds)
+        assertEquals(listOf<Long>(1, 2, 1, 1), ways[2]?.nodeIds)
+
+        assertTrue(updates.deleted.isEmpty())
+        assertEquals(listOf(ElementIdUpdate(NODE, -1, 1)), updates.idUpdates)
+    }
+
+    @Test fun `updates node id and all relations containing this id`() {
+        val updates = createMapDataUpdates(
+            elements = listOf(
+                node(-1),
+                rel(1, listOf(member(NODE, 3), member(NODE, -1))), // contains it once
+                rel(2, listOf(member(NODE, -1), member(NODE, 2), member(NODE, -1))), // contains it multiple times
+                rel(3, listOf(member(WAY, -1), member(RELATION, -1), member(NODE, 1))) // contains it not
+            ),
+            updates = mapOf(ElementKey(NODE, -1) to UpdateElement(1L, 1),)
+        )
+
+        val relations = updates.updated.filterIsInstance<Relation>().associateBy { it.id }
+        assertEquals(2, relations.size)
+        assertEquals(
+            listOf(member(NODE, 3), member(NODE, 1)),
+            relations[1]?.members
+        )
+        assertEquals(
+            listOf(member(NODE, 1), member(NODE, 2), member(NODE, 1)),
+            relations[2]?.members
+        )
+
+        assertTrue(updates.deleted.isEmpty())
+        assertEquals(listOf(ElementIdUpdate(NODE, -1, 1)), updates.idUpdates)
+    }
+
+    @Test fun `deletes node id and updates all ways containing this id`() {
+        val updates = createMapDataUpdates(
+            elements = listOf(
+                node(1),
+                way(1, listOf(3, 1)), // contains it once
+                way(2, listOf(1, 2, 1)), // contains it multiple times
+                way(3, listOf(3, 4)) // contains it not
+            ),
+            updates = mapOf(ElementKey(NODE, 1) to DeleteElement)
+        )
+
+        assertTrue(updates.idUpdates.isEmpty())
+        assertEquals(listOf(ElementKey(NODE, 1)), updates.deleted)
+
+        val ways = updates.updated.filterIsInstance<Way>().associateBy { it.id }
+        assertEquals(2, ways.size)
+        assertEquals(listOf<Long>(3), ways[1]?.nodeIds)
+        assertEquals(listOf<Long>(2), ways[2]?.nodeIds)
+    }
+
+    @Test fun `deletes node id and updates all relations containing this id`() {
+        val updates = createMapDataUpdates(
+            elements = listOf(
+                node(1),
+                rel(1, listOf(member(NODE, 3), member(NODE, 1))), // contains it once
+                rel(2, listOf(member(NODE, 1), member(NODE, 2), member(NODE, 1))), // contains it multiple times
+                rel(3, listOf(member(WAY, 1), member(RELATION, 1), member(NODE, 2))) // contains it not
+            ),
+            updates = mapOf(ElementKey(NODE, 1) to DeleteElement)
+        )
+        assertTrue(updates.idUpdates.isEmpty())
+        assertEquals(listOf(ElementKey(NODE, 1)), updates.deleted)
+
+        val relations = updates.updated.filterIsInstance<Relation>().associateBy { it.id }
+        assertEquals(2, relations.size)
+        assertEquals(listOf(member(NODE, 3)), relations[1]?.members)
+        assertEquals(listOf(member(NODE, 2)), relations[2]?.members)
+    }
+
+    @Test fun `does nothing with ignored relation types`() {
+        val updates = createMapDataUpdates(
+            elements = listOf(
+                rel(-4, tags = mapOf("type" to "route"))
+            ),
+            updates = mapOf(ElementKey(RELATION, -4) to UpdateElement(4, 1)),
+            ignoreRelationTypes = setOf("route")
+        )
+        assertTrue(updates.idUpdates.isEmpty())
+        assertTrue(updates.updated.isEmpty())
+        assertTrue(updates.deleted.isEmpty())
+    }
+
+    @Test fun `references to ignored relation types are updated`() {
+        val updates = createMapDataUpdates(
+            elements = listOf(
+                rel(1, members = listOf(member(RELATION, -4))),
+                rel(-4, tags = mapOf("type" to "route"))
+            ),
+            updates = mapOf(ElementKey(RELATION, -4) to UpdateElement(4, 1)),
+            ignoreRelationTypes = setOf("route")
+        )
+        assertTrue(updates.idUpdates.isEmpty())
+        assertEquals(
+            listOf(member(RELATION, 4)),
+            (updates.updated.single() as Relation).members
+        )
+        assertTrue(updates.deleted.isEmpty())
+    }
+}
diff --git a/app/src/test/java/de/westnordost/streetcomplete/data/osm/edits/upload/UpdatedElementsHandlerTest.kt b/app/src/test/java/de/westnordost/streetcomplete/data/osm/edits/upload/UpdatedElementsHandlerTest.kt
deleted file mode 100644
index 272bbf871dc..00000000000
--- a/app/src/test/java/de/westnordost/streetcomplete/data/osm/edits/upload/UpdatedElementsHandlerTest.kt
+++ /dev/null
@@ -1,201 +0,0 @@
-package de.westnordost.streetcomplete.data.osm.edits.upload
-
-import de.westnordost.streetcomplete.data.osm.mapdata.DiffElement
-import de.westnordost.streetcomplete.data.osm.mapdata.ElementIdUpdate
-import de.westnordost.streetcomplete.data.osm.mapdata.ElementKey
-import de.westnordost.streetcomplete.data.osm.mapdata.ElementType.NODE
-import de.westnordost.streetcomplete.data.osm.mapdata.ElementType.RELATION
-import de.westnordost.streetcomplete.data.osm.mapdata.ElementType.WAY
-import de.westnordost.streetcomplete.data.osm.mapdata.Relation
-import de.westnordost.streetcomplete.data.osm.mapdata.UpdatedElementsHandler
-import de.westnordost.streetcomplete.data.osm.mapdata.Way
-import de.westnordost.streetcomplete.testutils.member
-import de.westnordost.streetcomplete.testutils.node
-import de.westnordost.streetcomplete.testutils.rel
-import de.westnordost.streetcomplete.testutils.way
-import de.westnordost.streetcomplete.util.ktx.containsExactlyInAnyOrder
-import kotlin.test.Test
-import kotlin.test.assertEquals
-import kotlin.test.assertTrue
-
-class UpdatedElementsHandlerTest {
-    @Test fun `updates element version`() {
-        val handler = UpdatedElementsHandler()
-        handler.handle(DiffElement(NODE, 1, 1, 123))
-
-        val element = handler.getElementUpdates(listOf(node(1))).updated.single()
-        assertEquals(123, element.version)
-    }
-
-    @Test fun `deletes element`() {
-        val handler = UpdatedElementsHandler()
-        handler.handle(DiffElement(NODE, 1))
-
-        val deletedElementKey = handler.getElementUpdates(listOf(node(1))).deleted.single()
-        assertEquals(1, deletedElementKey.id)
-        assertEquals(NODE, deletedElementKey.type)
-    }
-
-    @Test fun `updates element id`() {
-        val handler = UpdatedElementsHandler()
-        handler.handle(DiffElement(NODE, -1, 123456, 1))
-
-        val element = handler.getElementUpdates(listOf(node(-1))).updated.single()
-        assertEquals(123456, element.id)
-    }
-
-    @Test fun `updates node id and all ways containing this id`() {
-        val elements = listOf(
-            node(-1),
-            way(1, listOf(3, 2, -1)), // contains it once
-            way(2, listOf(-1, 2, -1, -1)), // contains it multiple times
-            way(3, listOf(3, 4)) // contains it not
-        )
-        val handler = UpdatedElementsHandler()
-        handler.handleAll(
-            DiffElement(NODE, -1, 1, 1),
-            DiffElement(WAY, 1, 1, 2),
-            DiffElement(WAY, 2, 2, 2),
-            DiffElement(WAY, 3, 3, 2)
-        )
-
-        val updatedElements = handler.getElementUpdates(elements).updated
-        assertEquals(4, updatedElements.size)
-        val updatedWays = updatedElements.filterIsInstance<Way>()
-        assertEquals(3, updatedWays.size)
-        assertEquals(listOf(3L, 2L, 1L), updatedWays.find { it.id == 1L }!!.nodeIds)
-        assertEquals(listOf(1L, 2L, 1L, 1L), updatedWays.find { it.id == 2L }!!.nodeIds)
-        assertEquals(listOf(3L, 4L), updatedWays.find { it.id == 3L }!!.nodeIds)
-    }
-
-    @Test fun `updates node id and all relations containing this id`() {
-        val elements = listOf(
-            node(-1),
-            rel(1, listOf(member(NODE, 3), member(NODE, -1))), // contains it once
-            rel(2, listOf(member(NODE, -1), member(NODE, 2), member(NODE, -1))), // contains it multiple times
-            rel(3, listOf(member(WAY, -1), member(RELATION, -1), member(NODE, 1))) // contains it not
-        )
-        val handler = UpdatedElementsHandler()
-        handler.handle(DiffElement(NODE, -1, 1, 1))
-        handler.handleAll(
-            DiffElement(NODE, -1, 1, 1),
-            DiffElement(RELATION, 1, 1, 2),
-            DiffElement(RELATION, 2, 2, 2),
-            DiffElement(RELATION, 3, 3, 2)
-        )
-
-        val updatedElements = handler.getElementUpdates(elements).updated
-        assertEquals(4, updatedElements.size)
-        val updatedRelations = updatedElements.filterIsInstance<Relation>()
-        assertEquals(3, updatedRelations.size)
-        assertEquals(
-            listOf(member(NODE, 3), member(NODE, 1)),
-            updatedRelations.find { it.id == 1L }!!.members
-        )
-        assertEquals(
-            listOf(member(NODE, 1), member(NODE, 2), member(NODE, 1)),
-            updatedRelations.find { it.id == 2L }!!.members
-        )
-        assertEquals(
-            listOf(member(WAY, -1), member(RELATION, -1), member(NODE, 1)),
-            updatedRelations.find { it.id == 3L }!!.members
-        )
-    }
-
-    @Test fun `deletes node id and updates all ways containing this id`() {
-        val elements = listOf(
-            node(1),
-            way(1, listOf(3, 1)), // contains it once
-            way(2, listOf(1, 2, 1)), // contains it multiple times
-            way(3, listOf(3, 4)) // contains it not
-        )
-        val handler = UpdatedElementsHandler()
-        handler.handleAll(
-            DiffElement(NODE, 1),
-            DiffElement(WAY, 1, 1, 2),
-            DiffElement(WAY, 2, 2, 2),
-            DiffElement(WAY, 3, 3, 2)
-        )
-
-        val elementUpdates = handler.getElementUpdates(elements)
-        assertEquals(1, elementUpdates.deleted.size)
-        assertEquals(3, elementUpdates.updated.size)
-        val updatedWays = elementUpdates.updated.filterIsInstance<Way>()
-        assertEquals(3, updatedWays.size)
-        assertEquals(listOf(3L), updatedWays.find { it.id == 1L }!!.nodeIds)
-        assertEquals(listOf(2L), updatedWays.find { it.id == 2L }!!.nodeIds)
-        assertEquals(listOf(3L, 4L), updatedWays.find { it.id == 3L }!!.nodeIds)
-    }
-
-    @Test fun `deletes node id and updates all relations containing this id`() {
-        val elements = listOf(
-            node(1),
-            rel(1, listOf(member(NODE, 3), member(NODE, 1))), // contains it once
-            rel(2, listOf(member(NODE, 1), member(NODE, 2), member(NODE, 1))), // contains it multiple times
-            rel(3, listOf(member(WAY, 1), member(RELATION, 1), member(NODE, 2))) // contains it not
-        )
-        val handler = UpdatedElementsHandler()
-        handler.handleAll(
-            DiffElement(NODE, 1),
-            DiffElement(RELATION, 1, 1, 2),
-            DiffElement(RELATION, 2, 2, 2),
-            DiffElement(RELATION, 3, 3, 2)
-        )
-
-        val elementUpdates = handler.getElementUpdates(elements)
-        assertEquals(1, elementUpdates.deleted.size)
-        assertEquals(3, elementUpdates.updated.size)
-        val updatedRelations = elementUpdates.updated.filterIsInstance<Relation>()
-        assertEquals(3, updatedRelations.size)
-        assertEquals(
-            listOf(member(NODE, 3)),
-            updatedRelations.find { it.id == 1L }!!.members
-        )
-        assertEquals(
-            listOf(member(NODE, 2)),
-            updatedRelations.find { it.id == 2L }!!.members
-        )
-        assertEquals(
-            listOf(member(WAY, 1), member(RELATION, 1), member(NODE, 2)),
-            updatedRelations.find { it.id == 3L }!!.members
-        )
-    }
-
-    @Test fun `relays element id updates of non-deleted elements`() {
-        val elements = listOf(
-            node(-1),
-            node(-2),
-            way(-3, listOf()),
-            rel(-4, listOf())
-        )
-        val handler = UpdatedElementsHandler()
-        handler.handleAll(
-            DiffElement(NODE, -1, 11),
-            DiffElement(NODE, -2, null),
-            DiffElement(WAY, -3, 33),
-            DiffElement(RELATION, -4, 44)
-        )
-        val updates = handler.getElementUpdates(elements)
-        assertTrue(updates.idUpdates.containsExactlyInAnyOrder(listOf(
-            ElementIdUpdate(NODE, -1, 11),
-            ElementIdUpdate(WAY, -3, 33),
-            ElementIdUpdate(RELATION, -4, 44),
-        )))
-        updates.deleted.containsExactlyInAnyOrder(listOf(
-            ElementKey(NODE, -2)
-        ))
-    }
-
-    @Test fun `does nothing with ignored relation types`() {
-        val elements = listOf(rel(-4, listOf(), tags = mapOf("type" to "route")))
-        val ignoredRelationTypes = setOf("route")
-        val handler = UpdatedElementsHandler(ignoredRelationTypes)
-        handler.handle(DiffElement(RELATION, -4, 44))
-        val updates = handler.getElementUpdates(elements)
-        assertTrue(updates.idUpdates.isEmpty())
-    }
-}
-
-private fun UpdatedElementsHandler.handleAll(vararg diffs: DiffElement) {
-    diffs.forEach { handle(it) }
-}
diff --git a/app/src/test/java/de/westnordost/streetcomplete/data/osm/edits/upload/changesets/ChangesetApiClientTest.kt b/app/src/test/java/de/westnordost/streetcomplete/data/osm/edits/upload/changesets/ChangesetApiClientTest.kt
new file mode 100644
index 00000000000..445be033888
--- /dev/null
+++ b/app/src/test/java/de/westnordost/streetcomplete/data/osm/edits/upload/changesets/ChangesetApiClientTest.kt
@@ -0,0 +1,46 @@
+package de.westnordost.streetcomplete.data.osm.edits.upload.changesets
+
+import de.westnordost.streetcomplete.data.AuthorizationException
+import de.westnordost.streetcomplete.data.ConflictException
+import de.westnordost.streetcomplete.data.user.UserLoginSource
+import de.westnordost.streetcomplete.testutils.OsmDevApi
+import de.westnordost.streetcomplete.testutils.mock
+import de.westnordost.streetcomplete.testutils.on
+import io.ktor.client.HttpClient
+import kotlinx.coroutines.runBlocking
+import kotlin.test.Test
+import kotlin.test.assertFailsWith
+
+// other than some other APIs we are speaking to, we do not control the OSM API, so I think it is
+// more effective to test with the official test API instead of mocking some imagined server
+// response
+class ChangesetApiClientTest {
+    private val allowEverything = mock<UserLoginSource>()
+    private val allowNothing = mock<UserLoginSource>()
+    private val anonymous = mock<UserLoginSource>()
+
+    init {
+        on(allowEverything.accessToken).thenReturn(OsmDevApi.ALLOW_EVERYTHING_TOKEN)
+        on(allowNothing.accessToken).thenReturn(OsmDevApi.ALLOW_NOTHING_TOKEN)
+        on(anonymous.accessToken).thenReturn(null)
+    }
+
+    @Test fun `open throws exception on insufficient privileges`(): Unit = runBlocking {
+        assertFailsWith<AuthorizationException> { client(anonymous).open(mapOf()) }
+        assertFailsWith<AuthorizationException> { client(allowNothing).open(mapOf()) }
+    }
+
+    @Test fun `open and close works without error`(): Unit = runBlocking {
+        val id = client(allowEverything).open(mapOf("testKey" to "testValue"))
+        client(allowEverything).close(id)
+        assertFailsWith<ConflictException> { client(allowEverything).close(id) }
+    }
+
+    @Test fun `close throws exception on insufficient privileges`(): Unit = runBlocking {
+        assertFailsWith<AuthorizationException> { client(anonymous).close(1) }
+        assertFailsWith<AuthorizationException> { client(allowNothing).close(1) }
+    }
+
+    private fun client(userLoginSource: UserLoginSource) =
+        ChangesetApiClient(HttpClient(), OsmDevApi.URL, userLoginSource, ChangesetApiSerializer())
+}
diff --git a/app/src/test/java/de/westnordost/streetcomplete/data/osm/edits/upload/changesets/ChangesetApiSerializerTest.kt b/app/src/test/java/de/westnordost/streetcomplete/data/osm/edits/upload/changesets/ChangesetApiSerializerTest.kt
new file mode 100644
index 00000000000..edf43ec7d3c
--- /dev/null
+++ b/app/src/test/java/de/westnordost/streetcomplete/data/osm/edits/upload/changesets/ChangesetApiSerializerTest.kt
@@ -0,0 +1,28 @@
+package de.westnordost.streetcomplete.data.osm.edits.upload.changesets
+
+import kotlin.test.Test
+import kotlin.test.assertEquals
+
+class ChangesetApiSerializerTest {
+
+    @Test fun `serialize to xml`() {
+        val osm = """
+            <osm>
+            <changeset>
+            <tag k="one" v="1" />
+            <tag k="two" v="2" />
+            </changeset>
+            </osm>
+        """
+
+        val changesetTags = mapOf(
+            "one" to "1",
+            "two" to "2",
+        )
+
+        assertEquals(
+            osm.replace(Regex("[\n\r] *"), ""),
+            ChangesetApiSerializer().serialize(changesetTags)
+        )
+    }
+}
diff --git a/app/src/test/java/de/westnordost/streetcomplete/data/osm/edits/upload/changesets/OpenChangesetsManagerTest.kt b/app/src/test/java/de/westnordost/streetcomplete/data/osm/edits/upload/changesets/OpenChangesetsManagerTest.kt
index 7a609e2c790..06044559d7d 100644
--- a/app/src/test/java/de/westnordost/streetcomplete/data/osm/edits/upload/changesets/OpenChangesetsManagerTest.kt
+++ b/app/src/test/java/de/westnordost/streetcomplete/data/osm/edits/upload/changesets/OpenChangesetsManagerTest.kt
@@ -2,7 +2,7 @@ package de.westnordost.streetcomplete.data.osm.edits.upload.changesets
 
 import de.westnordost.streetcomplete.ApplicationConstants
 import de.westnordost.streetcomplete.data.osm.mapdata.LatLon
-import de.westnordost.streetcomplete.data.osm.mapdata.MapDataApi
+import de.westnordost.streetcomplete.data.osm.mapdata.MapDataApiClient
 import de.westnordost.streetcomplete.data.osm.osmquests.OsmElementQuestType
 import de.westnordost.streetcomplete.data.preferences.Preferences
 import de.westnordost.streetcomplete.data.quest.TestQuestTypeA
@@ -10,6 +10,7 @@ import de.westnordost.streetcomplete.testutils.any
 import de.westnordost.streetcomplete.testutils.mock
 import de.westnordost.streetcomplete.testutils.on
 import de.westnordost.streetcomplete.util.math.translate
+import kotlinx.coroutines.runBlocking
 import org.mockito.Mockito.never
 import org.mockito.Mockito.verify
 import java.util.Locale
@@ -20,7 +21,7 @@ import kotlin.test.assertEquals
 class OpenChangesetsManagerTest {
 
     private lateinit var questType: OsmElementQuestType<*>
-    private lateinit var mapDataApi: MapDataApi
+    private lateinit var changesetApiClient: ChangesetApiClient
     private lateinit var openChangesetsDB: OpenChangesetsDao
     private lateinit var changesetAutoCloser: ChangesetAutoCloser
     private lateinit var manager: OpenChangesetsManager
@@ -28,34 +29,34 @@ class OpenChangesetsManagerTest {
 
     @BeforeTest fun setUp() {
         questType = TestQuestTypeA()
-        mapDataApi = mock()
+        changesetApiClient = mock()
         openChangesetsDB = mock()
         changesetAutoCloser = mock()
         prefs = mock()
-        manager = OpenChangesetsManager(mapDataApi, openChangesetsDB, changesetAutoCloser, prefs)
+        manager = OpenChangesetsManager(changesetApiClient, openChangesetsDB, changesetAutoCloser, prefs)
     }
 
-    @Test fun `create new changeset if none exists`() {
+    @Test fun `create new changeset if none exists`(): Unit = runBlocking {
         on(openChangesetsDB.get(any(), any())).thenReturn(null)
-        on(mapDataApi.openChangeset(any())).thenReturn(123L)
+        on(changesetApiClient.open(any())).thenReturn(123L)
 
         assertEquals(123L, manager.getOrCreateChangeset(questType, "my source", LatLon(0.0, 0.0), false))
 
-        verify(mapDataApi).openChangeset(any())
+        verify(changesetApiClient).open(any())
         verify(openChangesetsDB).put(any())
     }
 
-    @Test fun `reuse changeset if one exists`() {
+    @Test fun `reuse changeset if one exists`(): Unit = runBlocking {
         on(openChangesetsDB.get(questType.name, "source")).thenReturn(
             OpenChangeset(questType.name, "source", 123, LatLon(0.0, 0.0))
         )
 
         assertEquals(123L, manager.getOrCreateChangeset(questType, "source", LatLon(0.0, 0.0), false))
 
-        verify(mapDataApi, never()).openChangeset(any())
+        verify(changesetApiClient, never()).open(any())
     }
 
-    @Test fun `reuse changeset if one exists and position is far away but should not create new if too far away`() {
+    @Test fun `reuse changeset if one exists and position is far away but should not create new if too far away`(): Unit = runBlocking {
         val p0 = LatLon(0.0, 0.0)
         val p1 = p0.translate(5001.0, 0.0)
 
@@ -63,35 +64,36 @@ class OpenChangesetsManagerTest {
             OpenChangeset(questType.name, "source", 123, p0)
         )
         assertEquals(123L, manager.getOrCreateChangeset(questType, "source", p1, false))
-        verify(mapDataApi, never()).openChangeset(any())
+        verify(changesetApiClient, never()).open(any())
     }
 
-    @Test fun `close changeset and create new if one exists and position is far away`() {
+    @Test fun `close changeset and create new if one exists and position is far away`(): Unit = runBlocking {
         val p0 = LatLon(0.0, 0.0)
         val p1 = p0.translate(5001.0, 0.0)
 
         on(openChangesetsDB.get(questType.name, "source")).thenReturn(
             OpenChangeset(questType.name, "source", 123, p0)
         )
-        on(mapDataApi.openChangeset(any())).thenReturn(124L)
+        on(changesetApiClient.open(any())).thenReturn(124L)
 
         assertEquals(124L, manager.getOrCreateChangeset(questType, "source", p1, true))
-        verify(mapDataApi).closeChangeset(123L)
-        verify(mapDataApi).openChangeset(any())
+        verify(changesetApiClient).close(123L)
+        verify(changesetApiClient).open(any())
         verify(openChangesetsDB).delete(questType.name, "source")
         verify(openChangesetsDB).put(OpenChangeset(questType.name, "source", 124L, p1))
     }
 
-    @Test fun `create correct changeset tags`() {
+    @Test fun `create correct changeset tags`(): Unit = runBlocking {
         on(openChangesetsDB.get(any(), any())).thenReturn(null)
         val locale = Locale.getDefault()
         Locale.setDefault(Locale("es", "AR"))
+        on(changesetApiClient.open(any())).thenReturn(1)
 
         manager.getOrCreateChangeset(questType, "my source", LatLon(0.0, 0.0), false)
 
         Locale.setDefault(locale)
 
-        verify(mapDataApi).openChangeset(mapOf(
+        verify(changesetApiClient).open(mapOf(
             "source" to "my source",
             "created_by" to ApplicationConstants.USER_AGENT,
             "comment" to "test me",
diff --git a/app/src/test/java/de/westnordost/streetcomplete/data/osm/mapdata/MapDataApiClientTest.kt b/app/src/test/java/de/westnordost/streetcomplete/data/osm/mapdata/MapDataApiClientTest.kt
new file mode 100644
index 00000000000..c32523dcf4f
--- /dev/null
+++ b/app/src/test/java/de/westnordost/streetcomplete/data/osm/mapdata/MapDataApiClientTest.kt
@@ -0,0 +1,217 @@
+package de.westnordost.streetcomplete.data.osm.mapdata
+
+import de.westnordost.streetcomplete.data.AuthorizationException
+import de.westnordost.streetcomplete.data.ConflictException
+import de.westnordost.streetcomplete.data.QueryTooBigException
+import de.westnordost.streetcomplete.data.osm.edits.upload.changesets.ChangesetApiClient
+import de.westnordost.streetcomplete.data.osm.edits.upload.changesets.ChangesetApiSerializer
+import de.westnordost.streetcomplete.data.user.UserLoginSource
+import de.westnordost.streetcomplete.testutils.OsmDevApi
+import de.westnordost.streetcomplete.testutils.mock
+import de.westnordost.streetcomplete.testutils.node
+import de.westnordost.streetcomplete.testutils.on
+import de.westnordost.streetcomplete.testutils.p
+import de.westnordost.streetcomplete.testutils.way
+import io.ktor.client.HttpClient
+import kotlinx.coroutines.runBlocking
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertFailsWith
+import kotlin.test.assertNotNull
+import kotlin.test.assertNull
+import kotlin.test.assertTrue
+
+class MapDataApiClientTest {
+
+    private val allowEverything = mock<UserLoginSource>()
+    private val allowNothing = mock<UserLoginSource>()
+    private val anonymous = mock<UserLoginSource>()
+
+    init {
+        on(allowEverything.accessToken).thenReturn(OsmDevApi.ALLOW_EVERYTHING_TOKEN)
+        on(allowNothing.accessToken).thenReturn(OsmDevApi.ALLOW_NOTHING_TOKEN)
+        on(anonymous.accessToken).thenReturn(null)
+    }
+
+    @Test fun getNode(): Unit = runBlocking {
+        assertEquals("Yangon", liveClient.getNode(NDDE_YANGON)?.tags?.get("name:en"))
+        assertNull(liveClient.getNode(0))
+    }
+
+    @Test fun getWay(): Unit = runBlocking {
+        assertEquals("Oderhafen", liveClient.getWay(WAY_ODERHAFEN)?.tags?.get("name"))
+        assertNull(liveClient.getWay(0))
+    }
+
+    @Test fun getRelation(): Unit = runBlocking {
+        assertEquals("Hamburg", liveClient.getRelation(RELATION_HAMBURG)?.tags?.get("name"))
+        assertNull(liveClient.getRelation(0))
+    }
+
+    @Test fun getWaysForNode(): Unit = runBlocking {
+        assertTrue(liveClient.getWaysForNode(VERTEX_OF_ELBPHILHARMONIE).isNotEmpty())
+        assertTrue(liveClient.getWaysForNode(0).isEmpty())
+    }
+
+    @Test fun getRelationsForNode(): Unit = runBlocking {
+        assertTrue(liveClient.getRelationsForNode(NODE_BUS_STATION).isNotEmpty())
+        assertTrue(liveClient.getRelationsForNode(0).isEmpty())
+    }
+
+    @Test fun getRelationsForWay(): Unit = runBlocking {
+        assertTrue(liveClient.getRelationsForWay(WAY_NEAR_BUS_STATION).isNotEmpty())
+        assertTrue(liveClient.getRelationsForWay(0).isEmpty())
+    }
+
+    @Test fun getRelationsForRelation(): Unit = runBlocking {
+        assertTrue(liveClient.getRelationsForRelation(RELATION_ONE_WAY_OF_BUS_ROUTE).isNotEmpty())
+        assertTrue(liveClient.getRelationsForRelation(0).isEmpty())
+    }
+
+    @Test fun getWayComplete(): Unit = runBlocking {
+        val data = liveClient.getWayComplete(WAY_ODERHAFEN)
+        assertNotNull(data)
+        assertTrue(data.nodes.isNotEmpty())
+        assertTrue(data.ways.size == 1)
+
+        assertNull(liveClient.getWayComplete(0))
+    }
+
+    @Test fun getRelationComplete(): Unit = runBlocking {
+        val data = liveClient.getRelationComplete(RELATION_HAMBURG)
+        assertNotNull(data)
+        assertTrue(data.nodes.isNotEmpty())
+        assertTrue(data.ways.isNotEmpty())
+
+        assertNull(liveClient.getRelationComplete(0))
+    }
+
+    @Test fun getMap(): Unit = runBlocking {
+        val hamburg = liveClient.getMap(HAMBURG_CITY_AREA)
+        assertTrue(hamburg.nodes.isNotEmpty())
+        assertTrue(hamburg.ways.isNotEmpty())
+        assertTrue(hamburg.relations.isNotEmpty())
+    }
+
+    @Test fun `getMap does not return relations of ignored type`(): Unit = runBlocking {
+        val hamburg = liveClient.getMap(AREA_NEAR_BUS_STATION, setOf("route"))
+        assertTrue(hamburg.relations.none { it.tags["type"] == "route" })
+    }
+
+    @Test fun `getMap fails when bbox crosses 180th meridian`(): Unit = runBlocking {
+        assertFailsWith<IllegalArgumentException> {
+            liveClient.getMap(BoundingBox(0.0, 179.9999999, 0.0000001, -179.9999999))
+        }
+    }
+
+    @Test fun `getMap fails when bbox is too big`(): Unit = runBlocking {
+        assertFailsWith<QueryTooBigException> {
+            liveClient.getMap(BoundingBox(-90.0, -180.0, 90.0, 180.0))
+        }
+    }
+
+    @Test fun `getMap returns bounding box that was specified in request`(): Unit = runBlocking {
+        val hamburg = liveClient.getMap(HAMBURG_CITY_AREA)
+        assertEquals(HAMBURG_CITY_AREA, hamburg.boundingBox)
+    }
+
+    @Test fun `uploadChanges as anonymous fails`(): Unit = runBlocking {
+        assertFailsWith<AuthorizationException> {
+            client(anonymous).uploadChanges(1L, MapDataChanges())
+        }
+    }
+
+    @Test fun `uploadChanges without authorization fails`(): Unit = runBlocking {
+        assertFailsWith<AuthorizationException> {
+            client(allowNothing).uploadChanges(1L, MapDataChanges())
+        }
+    }
+
+    @Test fun `uploadChanges in already closed changeset fails`(): Unit = runBlocking {
+        val changesetId = changesetClient(allowEverything).open(mapOf())
+        changesetClient(allowEverything).close(changesetId)
+        assertFailsWith<ConflictException> {
+            client(allowEverything).uploadChanges(changesetId, MapDataChanges())
+        }
+    }
+
+    @Test fun `uploadChanges of non-existing element fails`(): Unit = runBlocking {
+        val changesetId = changesetClient(allowEverything).open(mapOf())
+        assertFailsWith<ConflictException> {
+            client(allowEverything).uploadChanges(
+                changesetId = changesetId,
+                changes = MapDataChanges(modifications = listOf(node(Long.MAX_VALUE)))
+            )
+        }
+        changesetClient(allowEverything).close(changesetId)
+    }
+
+    @Test fun uploadChanges(): Unit = runBlocking {
+        val changesetId = changesetClient(allowEverything).open(mapOf())
+
+        val updates1 = client(allowEverything).uploadChanges(
+            changesetId = changesetId,
+            changes = MapDataChanges(
+                creations = listOf(
+                    node(-1, pos = p(15.0, -39.0), tags = mapOf("first" to "1")),
+                    node(-2, pos = p(15.0, -39.1), tags = mapOf("second" to "2")),
+                    node(-3, pos = p(15.0, -39.1), tags = mapOf("third" to "3")),
+                    way(-4, nodes = listOf(-1, -2, -3)),
+                )
+            )
+        )
+        assertEquals(
+            setOf(
+                ElementKey(ElementType.NODE, -1),
+                ElementKey(ElementType.NODE, -2),
+                ElementKey(ElementType.NODE, -3),
+                ElementKey(ElementType.WAY, -4),
+            ),
+            updates1.idUpdates.map { ElementKey(it.elementType, it.oldElementId) }.toSet()
+        )
+        assertEquals(4, updates1.updated.size)
+
+        val elements = updates1.updated
+        val firstNode = elements.find { it.tags["first"] == "1" } as Node
+        val secondNode = elements.find { it.tags["second"] == "2" } as Node
+        val thirdNode = elements.find { it.tags["third"] == "3" } as Node
+        val way = elements.filterIsInstance<Way>().single()
+
+        val updates2 = client(allowEverything).uploadChanges(
+            changesetId = changesetId,
+            changes = MapDataChanges(
+                modifications = listOf(way.copy(nodeIds = listOf(secondNode.id, thirdNode.id))),
+                deletions = listOf(firstNode),
+            )
+        )
+        assertTrue(updates2.idUpdates.isEmpty())
+        assertEquals(listOf(firstNode.key), updates2.deleted)
+        assertEquals(listOf(way.key), updates2.updated.map { it.key })
+
+        changesetClient(allowEverything).close(changesetId)
+    }
+
+    private fun client(userLoginSource: UserLoginSource) =
+        MapDataApiClient(HttpClient(), OsmDevApi.URL, userLoginSource, MapDataApiParser(), MapDataApiSerializer())
+
+    private fun changesetClient(userLoginSource: UserLoginSource) =
+        ChangesetApiClient(HttpClient(), OsmDevApi.URL, userLoginSource, ChangesetApiSerializer())
+
+    private val liveClient =
+        MapDataApiClient(HttpClient(), "https://api.openstreetmap.org/api/0.6/", anonymous, MapDataApiParser(), MapDataApiSerializer())
+
+    // some elements that should exist on the live API
+
+    private val NDDE_YANGON = 26576175L
+    private val WAY_ODERHAFEN = 23564402L
+    private val RELATION_HAMBURG = 451087L
+
+    private val VERTEX_OF_ELBPHILHARMONIE = 271454735L
+
+    private val NODE_BUS_STATION = 483688378L
+    private val WAY_NEAR_BUS_STATION = 148796410L
+    private val RELATION_ONE_WAY_OF_BUS_ROUTE = 36912L
+
+    private val AREA_NEAR_BUS_STATION = BoundingBox(53.6068315, 9.9046576, 53.6079471, 9.9062240)
+    private val HAMBURG_CITY_AREA = BoundingBox(53.579, 9.939, 53.580, 9.940)
+}
diff --git a/app/src/test/java/de/westnordost/streetcomplete/data/osm/mapdata/MapDataApiParserTest.kt b/app/src/test/java/de/westnordost/streetcomplete/data/osm/mapdata/MapDataApiParserTest.kt
new file mode 100644
index 00000000000..a24acee8089
--- /dev/null
+++ b/app/src/test/java/de/westnordost/streetcomplete/data/osm/mapdata/MapDataApiParserTest.kt
@@ -0,0 +1,78 @@
+package de.westnordost.streetcomplete.data.osm.mapdata
+
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertNull
+
+class MapDataApiParserTest {
+
+    @Test fun `parseMapData minimum`() {
+        val empty = MapDataApiParser().parseMapData("<osm></osm>", emptySet())
+        assertEquals(0, empty.size)
+        assertNull(empty.boundingBox)
+    }
+
+    @Test fun `parseMapData full`() {
+        val osm = """<?xml version="1.0" encoding="UTF-8"?>
+            <osm version="0.6" generator="CGImap 0.9.2 (2448320 spike-08.openstreetmap.org)" copyright="OpenStreetMap and contributors" attribution="http://www.openstreetmap.org/copyright" license="http://opendatacommons.org/licenses/odbl/1-0/">
+            <bounds minlat="53.0000000" minlon="9.0000000" maxlat="53.0100000" maxlon="9.0100000"/>
+            ${nodesOsm(123)}
+            ${waysOsm(345)}
+            ${relationsOsm(567)}
+            </osm>
+        """
+
+        val data = MapDataApiParser().parseMapData(osm, emptySet())
+        assertEquals(nodesList.toSet(), data.nodes.toSet())
+        assertEquals(waysList.toSet(), data.ways.toSet())
+        assertEquals(relationsList.toSet(), data.relations.toSet())
+        assertEquals(BoundingBox(53.0, 9.0, 53.01, 9.01), data.boundingBox)
+    }
+
+    @Test fun `parseMapData with ignored relation types`() {
+        val osm = """
+            <osm>
+            <relation id="1" version="1" timestamp="2023-05-08T14:14:51Z">
+              <tag k="type" v="route"/>
+            </relation>
+            </osm>
+        """
+
+        val empty = MapDataApiParser().parseMapData(osm, setOf("route"))
+        assertEquals(0, empty.size)
+    }
+
+    @Test fun `parseElementUpdates minimum`() {
+        assertEquals(
+            mapOf(),
+            MapDataApiParser().parseElementUpdates("<diffResult></diffResult>")
+        )
+    }
+
+    @Test fun `parseElementUpdates full`() {
+        val diffResult = """
+            <diffResult generator="OpenStreetMap Server" version="0.6">
+            <node old_id="1"/>
+            <way old_id="2"/>
+            <relation old_id="3"/>
+            <node old_id="-1" new_id="9" new_version="99" />
+            <way old_id="-2" new_id="8" new_version="88" />
+            <relation old_id="-3" new_id="7" new_version="77" />
+            </diffResult>
+        """
+
+        val elementUpdates = mapOf(
+            ElementKey(ElementType.NODE, 1) to DeleteElement,
+            ElementKey(ElementType.WAY, 2) to DeleteElement,
+            ElementKey(ElementType.RELATION, 3) to DeleteElement,
+            ElementKey(ElementType.NODE, -1) to UpdateElement(9, 99),
+            ElementKey(ElementType.WAY, -2) to UpdateElement(8, 88),
+            ElementKey(ElementType.RELATION, -3) to UpdateElement(7, 77),
+        )
+
+        assertEquals(
+            elementUpdates,
+            MapDataApiParser().parseElementUpdates(diffResult)
+        )
+    }
+}
diff --git a/app/src/test/java/de/westnordost/streetcomplete/data/osm/mapdata/MapDataApiSerializerTest.kt b/app/src/test/java/de/westnordost/streetcomplete/data/osm/mapdata/MapDataApiSerializerTest.kt
new file mode 100644
index 00000000000..57ebb0141d1
--- /dev/null
+++ b/app/src/test/java/de/westnordost/streetcomplete/data/osm/mapdata/MapDataApiSerializerTest.kt
@@ -0,0 +1,38 @@
+package de.westnordost.streetcomplete.data.osm.mapdata
+
+import kotlin.test.Test
+import kotlin.test.assertEquals
+
+class MapDataApiSerializerTest {
+
+    @Test fun `serializeMapDataChanges full`() {
+        val osmChange = """
+            <osmChange>
+            <create>
+            ${nodesOsm(1234)}
+            ${waysOsm(1234)}
+            </create>
+            <modify>
+            ${waysOsm(1234)}
+            ${relationsOsm(1234)}
+            </modify>
+            <delete>
+            ${nodesOsm(1234)}
+            ${relationsOsm(1234)}
+            </delete>
+            </osmChange>
+        """
+
+        val mapDataChanges = MapDataChanges(
+            creations = nodesList + waysList,
+            modifications = waysList + relationsList,
+            deletions = nodesList + relationsList,
+        )
+
+        assertEquals(
+            osmChange.replace(Regex("[\n\r] *"), ""),
+            MapDataApiSerializer().serializeMapDataChanges(mapDataChanges, 1234L)
+        )
+    }
+
+}
diff --git a/app/src/test/java/de/westnordost/streetcomplete/data/osm/mapdata/MapDataApiTestUtils.kt b/app/src/test/java/de/westnordost/streetcomplete/data/osm/mapdata/MapDataApiTestUtils.kt
new file mode 100644
index 00000000000..3894ab79862
--- /dev/null
+++ b/app/src/test/java/de/westnordost/streetcomplete/data/osm/mapdata/MapDataApiTestUtils.kt
@@ -0,0 +1,88 @@
+package de.westnordost.streetcomplete.data.osm.mapdata
+
+import kotlinx.datetime.Instant
+
+fun nodesOsm(c: Long): String = """
+        <node id="122" version="2" changeset="$c" timestamp="2019-03-15T01:52:26Z" lat="53.0098761" lon="9.0065254" />
+        <node id="123" version="1" changeset="$c" timestamp="2019-03-15T01:52:25Z" lat="53.009876" lon="9.0065253">
+        <tag k="emergency" v="fire_hydrant" />
+        <tag k="fire_hydrant:type" v="pillar" />
+        </node>
+    """
+
+fun waysOsm(c: Long): String = """
+        <way id="336145990" version="20" changeset="$c" timestamp="2018-10-17T06:39:01Z" />
+        <way id="47076097" version="2" changeset="$c" timestamp="2012-08-12T22:14:39Z">
+        <nd ref="600397018" />
+        <nd ref="600397019" />
+        <nd ref="600397020" />
+        <tag k="landuse" v="farmland" />
+        <tag k="name" v="Hippiefarm" />
+        </way>
+    """
+
+fun relationsOsm(c: Long): String = """
+        <relation id="55555" version="3" changeset="$c" timestamp="2021-05-08T14:14:51Z" />
+        <relation id="8379313" version="21" changeset="$c" timestamp="2023-05-08T14:14:51Z">
+        <member type="node" ref="123" role="something" />
+        <member type="way" ref="234" role="" />
+        <member type="relation" ref="345" role="connection" />
+        <tag k="network" v="rcn" />
+        <tag k="route" v="bicycle" />
+        </relation>
+    """
+
+val nodesList = listOf(
+    Node(
+        id = 122,
+        position = LatLon(53.0098761, 9.0065254),
+        tags = emptyMap(),
+        version = 2,
+        timestampEdited = Instant.parse("2019-03-15T01:52:26Z").toEpochMilliseconds()
+    ),
+    Node(
+        id = 123,
+        position = LatLon(53.0098760, 9.0065253),
+        tags = mapOf("emergency" to "fire_hydrant", "fire_hydrant:type" to "pillar"),
+        version = 1,
+        timestampEdited = Instant.parse("2019-03-15T01:52:25Z").toEpochMilliseconds()
+    ),
+)
+
+val waysList = listOf(
+    Way(
+        id = 336145990,
+        nodeIds = emptyList(),
+        tags = emptyMap(),
+        version = 20,
+        timestampEdited = Instant.parse("2018-10-17T06:39:01Z").toEpochMilliseconds()
+    ),
+    Way(
+        id = 47076097,
+        nodeIds = listOf(600397018, 600397019, 600397020),
+        tags = mapOf("landuse" to "farmland", "name" to "Hippiefarm"),
+        version = 2,
+        timestampEdited = Instant.parse("2012-08-12T22:14:39Z").toEpochMilliseconds()
+    ),
+)
+
+val relationsList = listOf(
+    Relation(
+        id = 55555,
+        members = emptyList(),
+        tags = emptyMap(),
+        version = 3,
+        timestampEdited = Instant.parse("2021-05-08T14:14:51Z").toEpochMilliseconds()
+    ),
+    Relation(
+        id = 8379313,
+        members = listOf(
+            RelationMember(ElementType.NODE, 123, "something"),
+            RelationMember(ElementType.WAY, 234, ""),
+            RelationMember(ElementType.RELATION, 345, "connection"),
+        ),
+        tags = mapOf("network" to "rcn", "route" to "bicycle"),
+        version = 21,
+        timestampEdited = Instant.parse("2023-05-08T14:14:51Z").toEpochMilliseconds()
+    )
+)
diff --git a/app/src/test/java/de/westnordost/streetcomplete/data/osmnotes/AvatarsDownloaderTest.kt b/app/src/test/java/de/westnordost/streetcomplete/data/osmnotes/AvatarsDownloaderTest.kt
index 6e142befafe..0d5eeb319e9 100644
--- a/app/src/test/java/de/westnordost/streetcomplete/data/osmnotes/AvatarsDownloaderTest.kt
+++ b/app/src/test/java/de/westnordost/streetcomplete/data/osmnotes/AvatarsDownloaderTest.kt
@@ -1,7 +1,7 @@
 package de.westnordost.streetcomplete.data.osmnotes
 
-import de.westnordost.osmapi.user.UserApi
-import de.westnordost.osmapi.user.UserInfo
+import de.westnordost.streetcomplete.data.user.UserApiClient
+import de.westnordost.streetcomplete.data.user.UserInfo
 import de.westnordost.streetcomplete.testutils.mock
 import de.westnordost.streetcomplete.testutils.on
 import io.ktor.client.HttpClient
@@ -13,66 +13,76 @@ import io.ktor.http.HttpStatusCode
 import io.ktor.utils.io.errors.IOException
 import kotlinx.coroutines.runBlocking
 import java.nio.file.Files
-import kotlin.test.BeforeTest
 import kotlin.test.Test
 import kotlin.test.assertEquals
 
 class AvatarsDownloaderTest {
-    private val mockEngine = MockEngine { request -> when (request.url.encodedPath) {
-        "/NotFound" -> respondError(HttpStatusCode.NotFound)
-        "/ConnectionError" -> throw IOException("Cannot connect")
-        else -> respondOk("Image Content")
-    } }
+    private val mockEngine = MockEngine { request ->
+        when (request.url.encodedPath) {
+            "/NotFound" -> respondError(HttpStatusCode.NotFound)
+            "/ConnectionError" -> throw IOException("Cannot connect")
+            else -> respondOk("Image Content")
+        }
+    }
     private val tempFolder = Files.createTempDirectory("images").toFile()
-    private val userApi: UserApi = mock()
+    private val userApi: UserApiClient = mock()
     private val downloader = AvatarsDownloader(HttpClient(mockEngine), userApi, tempFolder)
-    private val userInfo = UserInfo(100, "Map Enthusiast 530")
-
-    @BeforeTest fun setUp() {
-        userInfo.profileImageUrl = "http://example.com/BigImage.png"
-        on(userApi.get(userInfo.id)).thenReturn(userInfo)
-    }
 
     @Test
     fun `download makes GET request to profileImageUrl`() = runBlocking {
-        downloader.download(listOf(userInfo.id))
+        val user = user()
+        on(userApi.get(user.id)).thenReturn(user)
+
+        downloader.download(listOf(user.id))
 
         assertEquals(1, mockEngine.requestHistory.size)
-        assertEquals(userInfo.profileImageUrl, mockEngine.requestHistory[0].url.toString())
+        assertEquals(user.profileImageUrl, mockEngine.requestHistory[0].url.toString())
         assertEquals(HttpMethod.Get, mockEngine.requestHistory[0].method)
     }
 
     @Test
     fun `download copies HTTP response from profileImageUrl into tempFolder`() = runBlocking {
-        downloader.download(listOf(userInfo.id))
+        val user = user()
+        on(userApi.get(user.id)).thenReturn(user)
+
+        downloader.download(listOf(user.id))
 
         assertEquals("Image Content", tempFolder.resolve("100").readText())
     }
 
     @Test
     fun `download does not throw exception on HTTP NotFound`() = runBlocking {
-        userInfo.profileImageUrl = "http://example.com/NotFound"
+        val user = user(profileImageUrl = "http://example.com/NotFound")
+        on(userApi.get(user.id)).thenReturn(user)
 
-        downloader.download(listOf(userInfo.id))
+        downloader.download(listOf(user.id))
 
         assertEquals(404, mockEngine.responseHistory[0].statusCode.value)
     }
 
     @Test
     fun `download does not throw exception on networking error`() = runBlocking {
-        userInfo.profileImageUrl = "http://example.com/ConnectionError"
+        val user = user(profileImageUrl = "http://example.com/ConnectionError")
+        on(userApi.get(user.id)).thenReturn(user)
 
-        downloader.download(listOf(userInfo.id))
+        downloader.download(listOf(user.id))
 
         assertEquals(0, mockEngine.responseHistory.size)
     }
 
     @Test
     fun `download does not make HTTP request if profileImageUrl is NULL`() = runBlocking {
-        userInfo.profileImageUrl = null
+        val user = user(profileImageUrl = null)
+        on(userApi.get(user.id)).thenReturn(user)
 
-        downloader.download(listOf(userInfo.id))
+        downloader.download(listOf(user.id))
 
         assertEquals(0, mockEngine.requestHistory.size)
     }
+
+    private fun user(profileImageUrl: String? = "http://example.com/BigImage.png") = UserInfo(
+        id = 100,
+        displayName = "Map Enthusiast 530",
+        profileImageUrl = profileImageUrl,
+    )
 }
diff --git a/app/src/test/java/de/westnordost/streetcomplete/data/osmnotes/NotesApiClientTest.kt b/app/src/test/java/de/westnordost/streetcomplete/data/osmnotes/NotesApiClientTest.kt
new file mode 100644
index 00000000000..2c0ef64c9b9
--- /dev/null
+++ b/app/src/test/java/de/westnordost/streetcomplete/data/osmnotes/NotesApiClientTest.kt
@@ -0,0 +1,140 @@
+package de.westnordost.streetcomplete.data.osmnotes
+
+import de.westnordost.streetcomplete.data.AuthorizationException
+import de.westnordost.streetcomplete.data.ConflictException
+import de.westnordost.streetcomplete.data.QueryTooBigException
+import de.westnordost.streetcomplete.data.osm.mapdata.BoundingBox
+import de.westnordost.streetcomplete.data.osm.mapdata.LatLon
+import de.westnordost.streetcomplete.data.user.UserLoginSource
+import de.westnordost.streetcomplete.testutils.OsmDevApi
+import de.westnordost.streetcomplete.testutils.mock
+import de.westnordost.streetcomplete.testutils.on
+import io.ktor.client.HttpClient
+import io.ktor.client.request.bearerAuth
+import io.ktor.client.request.parameter
+import io.ktor.client.request.post
+import kotlinx.coroutines.runBlocking
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertFailsWith
+import kotlin.test.assertNull
+import kotlin.test.assertTrue
+
+// other than some other APIs we are speaking to, we do not control the OSM API, so I think it is
+// more effective to test with the official test API instead of mocking some imagined server
+// response
+class NotesApiClientTest {
+
+    private val allowEverything = mock<UserLoginSource>()
+    private val allowNothing = mock<UserLoginSource>()
+    private val anonymous = mock<UserLoginSource>()
+
+    init {
+        on(allowEverything.accessToken).thenReturn(OsmDevApi.ALLOW_EVERYTHING_TOKEN)
+        on(allowNothing.accessToken).thenReturn(OsmDevApi.ALLOW_NOTHING_TOKEN)
+        on(anonymous.accessToken).thenReturn(null)
+    }
+
+    @Test fun `create note`(): Unit = runBlocking {
+        val note = client(allowEverything).create(LatLon(83.0, 9.0), "Created note!")
+        closeNote(note.id)
+
+        assertEquals(LatLon(83.0, 9.0), note.position)
+        assertEquals(Note.Status.OPEN, note.status)
+        assertEquals(1, note.comments.size)
+
+        val comment = note.comments.first()
+        assertEquals("Created note!", comment.text)
+        assertEquals(NoteComment.Action.OPENED, comment.action)
+        assertEquals("westnordost", comment.user?.displayName)
+    }
+
+    @Test fun `comment note`(): Unit = runBlocking {
+        var note = client(allowEverything).create(LatLon(83.0, 9.1), "Created note for comment!")
+        note = client(allowEverything).comment(note.id, "First comment!")
+        closeNote(note.id)
+
+        assertEquals(2, note.comments.size)
+        assertEquals("Created note for comment!", note.comments[0].text)
+        assertEquals(NoteComment.Action.OPENED, note.comments[0].action)
+        assertEquals("westnordost", note.comments[0].user?.displayName)
+
+        assertEquals("First comment!", note.comments[1].text)
+        assertEquals(NoteComment.Action.COMMENTED, note.comments[1].action)
+        assertEquals("westnordost", note.comments[1].user?.displayName)
+    }
+
+    @Test fun `comment note fails when not logged in`(): Unit = runBlocking {
+        val note = client(allowEverything).create(LatLon(83.0, 9.1), "Created note for comment!")
+        assertFailsWith<AuthorizationException> {
+            client(anonymous).comment(note.id, "test")
+        }
+        closeNote(note.id)
+    }
+
+    @Test fun `comment note fails when not authorized`(): Unit = runBlocking {
+        val note = client(allowEverything).create(LatLon(83.0, 9.1), "Created note for comment!")
+        assertFailsWith<AuthorizationException> {
+            client(allowNothing).comment(note.id, "test")
+        }
+        closeNote(note.id)
+    }
+
+    @Test fun `comment note fails when already closed`(): Unit = runBlocking {
+        val note = client(allowEverything).create(LatLon(83.0, 9.1), "Created note for comment!")
+        closeNote(note.id)
+        assertFailsWith<ConflictException> {
+            client(allowEverything).comment(note.id, "test")
+        }
+    }
+
+    @Test fun `get note`(): Unit = runBlocking {
+        val note = client(allowEverything).create(LatLon(83.0, 9.2), "Created note to get it!")
+        val note2 = client(anonymous).get(note.id)
+        closeNote(note.id)
+
+        assertEquals(note, note2)
+    }
+
+    @Test fun `get no note`(): Unit = runBlocking {
+        assertNull(client(anonymous).get(0))
+    }
+
+    @Test fun `get notes`(): Unit = runBlocking {
+        val note1 = client(allowEverything).create(LatLon(83.0, 9.3), "Note a")
+        val note2 = client(allowEverything).create(LatLon(83.1, 9.4), "Note b")
+
+        val notes = client(anonymous).getAllOpen(BoundingBox(83.0, 9.3, 83.2, 9.5))
+
+        closeNote(note1.id)
+        closeNote(note2.id)
+
+        assertTrue(notes.isNotEmpty())
+    }
+
+    @Test fun `get notes fails when bbox crosses 180th meridian`(): Unit = runBlocking {
+        assertFailsWith<IllegalArgumentException> {
+            client(anonymous).getAllOpen(BoundingBox(0.0, 179.0, 0.1, -179.0))
+        }
+    }
+
+    @Test fun `get notes fails when limit is too large`(): Unit = runBlocking {
+        assertFailsWith<QueryTooBigException> {
+            client(anonymous).getAllOpen(BoundingBox(0.0, 0.0, 0.1, 0.1), 100000000)
+        }
+        assertFailsWith<QueryTooBigException> {
+            client(anonymous).getAllOpen(BoundingBox(0.0, 0.0, 90.0, 90.0))
+        }
+    }
+
+    private fun client(userLoginSource: UserLoginSource) =
+        NotesApiClient(HttpClient(), OsmDevApi.URL, userLoginSource, NotesApiParser())
+
+    // for cleanup
+    private fun closeNote(id: Long): Unit = runBlocking {
+        HttpClient().post(OsmDevApi.URL + "notes/$id/close") {
+            bearerAuth(OsmDevApi.ALLOW_EVERYTHING_TOKEN)
+            parameter("text", "")
+        }
+    }
+}
diff --git a/app/src/test/java/de/westnordost/streetcomplete/data/osmnotes/NotesApiParserTest.kt b/app/src/test/java/de/westnordost/streetcomplete/data/osmnotes/NotesApiParserTest.kt
new file mode 100644
index 00000000000..2fb2141bd5b
--- /dev/null
+++ b/app/src/test/java/de/westnordost/streetcomplete/data/osmnotes/NotesApiParserTest.kt
@@ -0,0 +1,126 @@
+package de.westnordost.streetcomplete.data.osmnotes
+
+import de.westnordost.streetcomplete.data.osm.mapdata.LatLon
+import de.westnordost.streetcomplete.data.user.User
+import kotlinx.datetime.Instant
+import kotlin.test.Test
+import kotlin.test.assertEquals
+
+class NotesApiParserTest {
+
+    @Test fun `parse one minimum note`() {
+        val xml = """
+        <osm>
+          <note lon="0.0689357" lat="51.5085707">
+            <id>1</id>
+            <date_created>2024-06-06 12:47:50 UTC</date_created>
+            <status>open</status>
+          </note>
+        </osm>
+        """.trimIndent()
+
+        val note = Note(
+            position = LatLon(51.5085707, 0.0689357),
+            id = 1,
+            timestampCreated = Instant.parse("2024-06-06T12:47:50Z").toEpochMilliseconds(),
+            timestampClosed = null,
+            status = Note.Status.OPEN,
+            comments = listOf()
+        )
+
+        assertEquals(listOf(note), NotesApiParser().parseNotes(xml))
+    }
+
+    @Test fun `parse one full note`() {
+        val xml = """
+        <osm version="0.6" generator="OpenStreetMap server" copyright="OpenStreetMap and contributors" attribution="http://www.openstreetmap.org/copyright" license="http://opendatacommons.org/licenses/odbl/1-0/">
+          <note lon="0.0689357" lat="51.5085707">
+            <id>1</id>
+            <url>https://api.openstreetmap.org/api/0.6/notes/1</url>
+            <comment_url>https://api.openstreetmap.org/api/0.6/notes/1/comment</comment_url>
+            <close_url>https://api.openstreetmap.org/api/0.6/notes/1/close</close_url>
+            <date_created>2024-06-06 12:47:50 UTC</date_created>
+            <status>closed</status>
+            <date_closed>2024-06-06 12:47:51 UTC</date_closed>
+            <comments>
+              <comment>
+                <date>2024-06-06 12:47:50 UTC</date>
+                <uid>1234</uid>
+                <user>dude</user>
+                <user_url>https://api.openstreetmap.org/user/dude</user_url>
+                <action>opened</action>
+                <text>I opened it!</text>
+                <html><p>Some</p><p>text</p></html>
+              </comment>
+              <comment>
+                <date>2024-06-06 12:47:51 UTC</date>
+                <action>closed</action>
+              </comment>
+            </comments>
+          </note>
+        </osm>
+        """.trimIndent()
+
+        val note = Note(
+            position = LatLon(51.5085707, 0.0689357),
+            id = 1,
+            timestampCreated = Instant.parse("2024-06-06T12:47:50Z").toEpochMilliseconds(),
+            timestampClosed = Instant.parse("2024-06-06T12:47:51Z").toEpochMilliseconds(),
+            status = Note.Status.CLOSED,
+            comments = listOf(
+                NoteComment(
+                    timestamp = Instant.parse("2024-06-06T12:47:50Z").toEpochMilliseconds(),
+                    action = NoteComment.Action.OPENED,
+                    text = "I opened it!",
+                    user = User(1234, "dude")
+                ),
+                NoteComment(
+                    timestamp = Instant.parse("2024-06-06T12:47:51Z").toEpochMilliseconds(),
+                    action = NoteComment.Action.CLOSED,
+                    text = null,
+                    user = null
+                ),
+            )
+        )
+
+        assertEquals(listOf(note), NotesApiParser().parseNotes(xml))
+    }
+
+    @Test fun `parse several notes`() {
+        val xml = """
+        <osm>
+          <note lon="0.0689357" lat="51.5085707">
+            <id>1</id>
+            <date_created>2024-06-06 12:47:50 UTC</date_created>
+            <status>open</status>
+          </note>
+          <note lon="0.0689359" lat="51.5085709">
+            <id>2</id>
+            <date_created>2024-06-06 12:47:51 UTC</date_created>
+            <status>hidden</status>
+          </note>
+        </osm>
+        """.trimIndent()
+
+        val notes = listOf(
+            Note(
+                position = LatLon(51.5085707, 0.0689357),
+                id = 1,
+                timestampCreated = Instant.parse("2024-06-06T12:47:50Z").toEpochMilliseconds(),
+                timestampClosed = null,
+                status = Note.Status.OPEN,
+                comments = listOf()
+            ),
+            Note(
+                position = LatLon(51.5085709, 0.0689359),
+                id = 2,
+                timestampCreated = Instant.parse("2024-06-06T12:47:51Z").toEpochMilliseconds(),
+                timestampClosed = null,
+                status = Note.Status.HIDDEN,
+                comments = listOf()
+            ),
+        )
+
+        assertEquals(notes, NotesApiParser().parseNotes(xml))
+    }
+}
diff --git a/app/src/test/java/de/westnordost/streetcomplete/data/osmnotes/NotesDownloaderTest.kt b/app/src/test/java/de/westnordost/streetcomplete/data/osmnotes/NotesDownloaderTest.kt
index c7f20ef31d7..f4ddddf450c 100644
--- a/app/src/test/java/de/westnordost/streetcomplete/data/osmnotes/NotesDownloaderTest.kt
+++ b/app/src/test/java/de/westnordost/streetcomplete/data/osmnotes/NotesDownloaderTest.kt
@@ -14,7 +14,7 @@ import kotlin.test.Test
 
 class NotesDownloaderTest {
     private lateinit var noteController: NoteController
-    private lateinit var notesApi: NotesApi
+    private lateinit var notesApi: NotesApiClient
 
     @BeforeTest fun setUp() {
         noteController = mock()
@@ -25,7 +25,7 @@ class NotesDownloaderTest {
         val note1 = note()
         val bbox = bbox()
 
-        on(notesApi.getAll(any(), anyInt(), anyInt())).thenReturn(listOf(note1))
+        on(notesApi.getAllOpen(any(), anyInt())).thenReturn(listOf(note1))
         val dl = NotesDownloader(notesApi, noteController)
         dl.download(bbox)
 
diff --git a/app/src/test/java/de/westnordost/streetcomplete/data/osmnotes/PhotoServiceApiClientTest.kt b/app/src/test/java/de/westnordost/streetcomplete/data/osmnotes/PhotoServiceApiClientTest.kt
new file mode 100644
index 00000000000..0c17f27c2d1
--- /dev/null
+++ b/app/src/test/java/de/westnordost/streetcomplete/data/osmnotes/PhotoServiceApiClientTest.kt
@@ -0,0 +1,95 @@
+package de.westnordost.streetcomplete.data.osmnotes
+
+import de.westnordost.streetcomplete.data.ConnectionException
+import io.ktor.client.HttpClient
+import io.ktor.client.engine.HttpClientEngine
+import io.ktor.client.engine.mock.MockEngine
+import io.ktor.client.engine.mock.respondError
+import io.ktor.client.engine.mock.respondOk
+import io.ktor.client.engine.mock.toByteArray
+import io.ktor.http.ContentType
+import io.ktor.http.HttpMethod
+import io.ktor.http.HttpStatusCode
+import io.ktor.utils.io.errors.IOException
+import kotlinx.coroutines.runBlocking
+import kotlinx.serialization.SerializationException
+import kotlin.test.Test
+import kotlin.test.assertContentEquals
+import kotlin.test.assertEquals
+import kotlin.test.assertFailsWith
+
+class PhotoServiceApiClientTest {
+
+    private val picture = "src/test/resources/hai_phong_street.jpg"
+
+    @Test
+    fun `upload makes POST request with file contents and returns response`() = runBlocking {
+        val mockEngine = MockEngine { respondOk("{\"future_url\": \"market.jpg\"}") }
+        val client = PhotoServiceApiClient(HttpClient(mockEngine), "http://example.com/")
+
+        val response = client.upload(listOf(picture))
+
+        assertEquals(1, mockEngine.requestHistory.size)
+        assertEquals(HttpMethod.Post, mockEngine.requestHistory[0].method)
+        assertEquals("http://example.com/upload.php", mockEngine.requestHistory[0].url.toString())
+        assertEquals(ContentType.Image.JPEG, mockEngine.requestHistory[0].body.contentType)
+        assertEquals("binary", mockEngine.requestHistory[0].headers["Content-Transfer-Encoding"])
+
+        assertContentEquals(listOf("market.jpg"), response)
+    }
+
+    @Test
+    fun `upload throws ConnectionException on such errors`(): Unit = runBlocking {
+        val pics = listOf("src/test/resources/hai_phong_street.jpg")
+
+        assertFailsWith(ConnectionException::class) {
+            client(MockEngine { throw IOException() }).upload(pics)
+        }
+        assertFailsWith(ConnectionException::class) {
+            client(MockEngine { respondError(HttpStatusCode.InternalServerError) }).upload(pics)
+        }
+        assertFailsWith(ConnectionException::class) {
+            client(MockEngine { throw SerializationException() }).upload(pics)
+        }
+    }
+
+    @Test
+    fun `upload performs no requests with missing file`() = runBlocking {
+        val mockEngine = MockEngine { respondOk() }
+        val client = PhotoServiceApiClient(HttpClient(mockEngine), "http://example.com/")
+
+        assertContentEquals(listOf(), client.upload(listOf("no-such-file-at-this-path.jpg")))
+        assertEquals(0, mockEngine.requestHistory.size)
+    }
+
+    @Test
+    fun `activate makes POST request with note ID`() = runBlocking {
+        val mockEngine = MockEngine { respondOk() }
+        val client = PhotoServiceApiClient(HttpClient(mockEngine), "http://example.com/")
+
+        client.activate(123)
+
+        assertEquals(1, mockEngine.requestHistory.size)
+        assertEquals("http://example.com/activate.php", mockEngine.requestHistory[0].url.toString())
+        assertEquals(ContentType.Application.Json, mockEngine.requestHistory[0].body.contentType)
+        assertEquals("{\"osm_note_id\": 123}", String(mockEngine.requestHistory[0].body.toByteArray()))
+    }
+
+    @Test
+    fun `activate throws ConnectionException on such errors`(): Unit = runBlocking {
+        assertFailsWith(ConnectionException::class) {
+            client(MockEngine { respondError(HttpStatusCode.InternalServerError) }).activate(1)
+        }
+        assertFailsWith(ConnectionException::class) {
+            client(MockEngine { throw IOException() }).activate(1)
+        }
+    }
+
+    @Test
+    fun `activate ignores error code 410 (gone)`(): Unit = runBlocking {
+        client(MockEngine { respondError(HttpStatusCode.Gone) }).activate(1)
+    }
+
+    private fun client(engine: HttpClientEngine) =
+        PhotoServiceApiClient(HttpClient(engine), "http://example.com/")
+}
diff --git a/app/src/test/java/de/westnordost/streetcomplete/data/osmnotes/StreetCompleteImageUploaderTest.kt b/app/src/test/java/de/westnordost/streetcomplete/data/osmnotes/StreetCompleteImageUploaderTest.kt
deleted file mode 100644
index 83469a345b3..00000000000
--- a/app/src/test/java/de/westnordost/streetcomplete/data/osmnotes/StreetCompleteImageUploaderTest.kt
+++ /dev/null
@@ -1,121 +0,0 @@
-package de.westnordost.streetcomplete.data.osmnotes
-
-import de.westnordost.streetcomplete.data.ConnectionException
-import io.ktor.client.HttpClient
-import io.ktor.client.engine.mock.MockEngine
-import io.ktor.client.engine.mock.respondBadRequest
-import io.ktor.client.engine.mock.respondError
-import io.ktor.client.engine.mock.respondOk
-import io.ktor.client.engine.mock.toByteArray
-import io.ktor.http.ContentType
-import io.ktor.http.HttpMethod
-import io.ktor.http.HttpStatusCode
-import io.ktor.utils.io.errors.IOException
-import kotlinx.coroutines.runBlocking
-import kotlin.test.Test
-import kotlin.test.assertContentEquals
-import kotlin.test.assertEquals
-import kotlin.test.assertFailsWith
-
-class StreetCompleteImageUploaderTest {
-    private val mockEngine = MockEngine { request -> when (String(request.body.toByteArray())) {
-        // Upload requests
-        "valid\n" -> respondOk("{\"future_url\": \"market.jpg\"}")
-        "invalid\n" -> respondError(HttpStatusCode.InternalServerError)
-        "" -> respondBadRequest()
-        "ioexception\n" -> throw IOException("Unable to connect")
-
-        // Activate requests
-        "{\"osm_note_id\": 180}" -> respondOk()
-        "{\"osm_note_id\": 190}" -> respondError(HttpStatusCode.InternalServerError)
-        "{\"osm_note_id\": 200}" -> respondBadRequest()
-        "{\"osm_note_id\": 210}" -> throw IOException("Unable to connect")
-
-        else -> throw Exception("Invalid request body")
-    } }
-    private val uploader = StreetCompleteImageUploader(HttpClient(mockEngine), "http://example.com/" )
-
-    @Test
-    fun `upload makes POST request with file contents`() = runBlocking {
-        uploader.upload(listOf("src/test/resources/image_uploader/valid.jpg"))
-
-        assertEquals(1, mockEngine.requestHistory.size)
-        assertEquals(HttpMethod.Post, mockEngine.requestHistory[0].method)
-        assertEquals("http://example.com/upload.php", mockEngine.requestHistory[0].url.toString())
-        assertEquals(ContentType.Image.JPEG, mockEngine.requestHistory[0].body.contentType)
-        assertEquals("binary", mockEngine.requestHistory[0].headers["Content-Transfer-Encoding"])
-    }
-
-    @Test
-    fun `upload returns future_url value from response`() = runBlocking {
-        val uploads = uploader.upload(listOf("src/test/resources/image_uploader/valid.jpg"))
-
-        assertContentEquals(listOf("market.jpg"), uploads)
-    }
-
-    @Test
-    fun `upload throws ImageUploadServerException on 500 error`() = runBlocking {
-        val exception = assertFailsWith(ImageUploadServerException::class) {
-            uploader.upload(listOf("src/test/resources/image_uploader/invalid.jpg"))
-        }
-
-        assertEquals("Upload failed: Error code 500 Internal Server Error, Message: \"Internal Server Error\"", exception.message)
-    }
-
-    @Test
-    fun `upload throws ImageUploadClientException on 400 error`() = runBlocking {
-        val exception = assertFailsWith(ImageUploadClientException::class) {
-            uploader.upload(listOf("src/test/resources/image_uploader/empty.jpg"))
-        }
-
-        assertEquals("Upload failed: Error code 400 Bad Request, Message: \"Bad Request\"", exception.message)
-    }
-
-    @Test
-    fun `upload performs no requests with missing file`() = runBlocking {
-        assertContentEquals(listOf(), uploader.upload(listOf("no-such-file-at-this-path.jpg")))
-        assertEquals(0, mockEngine.requestHistory.size)
-    }
-
-    @Test
-    fun `upload throws ConnectionException on IOException`(): Unit = runBlocking {
-        assertFailsWith(ConnectionException::class) {
-            uploader.upload(listOf("src/test/resources/image_uploader/ioexception.jpg"))
-        }
-    }
-
-    @Test
-    fun `activate makes POST request with note ID`() = runBlocking {
-        uploader.activate(180)
-
-        assertEquals(1, mockEngine.requestHistory.size)
-        assertEquals("http://example.com/activate.php", mockEngine.requestHistory[0].url.toString())
-        assertEquals(ContentType.Application.Json, mockEngine.requestHistory[0].body.contentType)
-        assertEquals("{\"osm_note_id\": 180}", String(mockEngine.requestHistory[0].body.toByteArray()))
-    }
-
-    @Test
-    fun `activate throws ImageUploadServerException on 500 error`() = runBlocking {
-        val exception = assertFailsWith(ImageUploadServerException::class) {
-            uploader.activate(190)
-        }
-
-        assertEquals("Error code 500 Internal Server Error, Message: \"Internal Server Error\"", exception.message)
-    }
-
-    @Test
-    fun `activate throws ImageUploadClientException on 400 error`() = runBlocking {
-        val exception = assertFailsWith(ImageUploadClientException::class) {
-            uploader.activate(200)
-        }
-
-        assertEquals("Error code 400 Bad Request, Message: \"Bad Request\"", exception.message)
-    }
-
-    @Test
-    fun `activate throws ConnectionException on IOException`(): Unit = runBlocking {
-        assertFailsWith(ConnectionException::class) {
-            uploader.activate(210)
-        }
-    }
-}
diff --git a/app/src/test/java/de/westnordost/streetcomplete/data/osmnotes/edits/NoteEditsUploaderTest.kt b/app/src/test/java/de/westnordost/streetcomplete/data/osmnotes/edits/NoteEditsUploaderTest.kt
index a288820f041..08475189d6c 100644
--- a/app/src/test/java/de/westnordost/streetcomplete/data/osmnotes/edits/NoteEditsUploaderTest.kt
+++ b/app/src/test/java/de/westnordost/streetcomplete/data/osmnotes/edits/NoteEditsUploaderTest.kt
@@ -3,10 +3,10 @@ package de.westnordost.streetcomplete.data.osmnotes.edits
 import de.westnordost.streetcomplete.data.ConflictException
 import de.westnordost.streetcomplete.data.osm.mapdata.LatLon
 import de.westnordost.streetcomplete.data.osmnotes.NoteController
-import de.westnordost.streetcomplete.data.osmnotes.NotesApi
-import de.westnordost.streetcomplete.data.osmnotes.StreetCompleteImageUploader
+import de.westnordost.streetcomplete.data.osmnotes.NotesApiClient
+import de.westnordost.streetcomplete.data.osmnotes.PhotoServiceApiClient
 import de.westnordost.streetcomplete.data.osmtracks.Trackpoint
-import de.westnordost.streetcomplete.data.osmtracks.TracksApi
+import de.westnordost.streetcomplete.data.osmtracks.TracksApiClient
 import de.westnordost.streetcomplete.data.upload.OnUploadedChangeListener
 import de.westnordost.streetcomplete.data.user.UserDataSource
 import de.westnordost.streetcomplete.testutils.any
@@ -29,15 +29,15 @@ class NoteEditsUploaderTest {
 
     private lateinit var noteController: NoteController
     private lateinit var noteEditsController: NoteEditsController
-    private lateinit var notesApi: NotesApi
-    private lateinit var tracksApi: TracksApi
-    private lateinit var imageUploader: StreetCompleteImageUploader
+    private lateinit var notesApi: NotesApiClient
+    private lateinit var tracksApi: TracksApiClient
+    private lateinit var imageUploader: PhotoServiceApiClient
     private lateinit var userDataSource: UserDataSource
 
     private lateinit var uploader: NoteEditsUploader
     private lateinit var listener: OnUploadedChangeListener
 
-    @BeforeTest fun setUp() {
+    @BeforeTest fun setUp(): Unit = runBlocking {
         notesApi = mock()
         noteController = mock()
         noteEditsController = mock()
@@ -63,7 +63,7 @@ class NoteEditsUploaderTest {
         verifyNoInteractions(noteEditsController, noteController, notesApi, imageUploader)
     }
 
-    @Test fun `upload note comment`() {
+    @Test fun `upload note comment`(): Unit = runBlocking {
         val pos = p(1.0, 13.0)
         val edit = noteEdit(noteId = 1L, action = NoteEditAction.COMMENT, text = "abc", pos = pos)
         val note = note(id = 1L)
@@ -80,7 +80,7 @@ class NoteEditsUploaderTest {
         verify(listener)!!.onUploaded("NOTE", pos)
     }
 
-    @Test fun `upload create note`() {
+    @Test fun `upload create note`(): Unit = runBlocking {
         val pos = p(1.0, 13.0)
         val edit = noteEdit(noteId = -5L, action = NoteEditAction.CREATE, text = "abc", pos = pos)
         val note = note(123)
@@ -97,7 +97,7 @@ class NoteEditsUploaderTest {
         verify(listener)!!.onUploaded("NOTE", pos)
     }
 
-    @Test fun `fail uploading note comment because of a conflict`() {
+    @Test fun `fail uploading note comment because of a conflict`(): Unit = runBlocking {
         val pos = p(1.0, 13.0)
         val edit = noteEdit(noteId = 1L, action = NoteEditAction.COMMENT, text = "abc", pos = pos)
         val note = note(1)
@@ -115,7 +115,7 @@ class NoteEditsUploaderTest {
         verify(listener)!!.onDiscarded("NOTE", pos)
     }
 
-    @Test fun `fail uploading note comment because note was deleted`() {
+    @Test fun `fail uploading note comment because note was deleted`(): Unit = runBlocking {
         val pos = p(1.0, 13.0)
         val edit = noteEdit(noteId = 1L, action = NoteEditAction.COMMENT, text = "abc", pos = pos)
         val note = note(1)
@@ -133,7 +133,7 @@ class NoteEditsUploaderTest {
         verify(listener)!!.onDiscarded("NOTE", pos)
     }
 
-    @Test fun `upload several note edits`() {
+    @Test fun `upload several note edits`(): Unit = runBlocking {
         on(noteEditsController.getOldestUnsynced()).thenReturn(noteEdit()).thenReturn(noteEdit()).thenReturn(null)
         on(notesApi.comment(anyLong(), any())).thenReturn(note())
 
diff --git a/app/src/test/java/de/westnordost/streetcomplete/data/osmtracks/TracksApiClientTest.kt b/app/src/test/java/de/westnordost/streetcomplete/data/osmtracks/TracksApiClientTest.kt
new file mode 100644
index 00000000000..cb904117689
--- /dev/null
+++ b/app/src/test/java/de/westnordost/streetcomplete/data/osmtracks/TracksApiClientTest.kt
@@ -0,0 +1,44 @@
+package de.westnordost.streetcomplete.data.osmtracks
+
+import de.westnordost.streetcomplete.data.AuthorizationException
+import de.westnordost.streetcomplete.data.osm.mapdata.LatLon
+import de.westnordost.streetcomplete.data.user.UserLoginSource
+import de.westnordost.streetcomplete.testutils.OsmDevApi
+import de.westnordost.streetcomplete.testutils.mock
+import de.westnordost.streetcomplete.testutils.on
+import io.ktor.client.HttpClient
+import kotlinx.coroutines.runBlocking
+import java.time.Instant
+import kotlin.test.Test
+import kotlin.test.assertFailsWith
+
+// other than some other APIs we are speaking to, we do not control the OSM API, so I think it is
+// more effective to test with the official test API instead of mocking some imagined server
+// response
+class TracksApiClientTest {
+
+    private val trackpoint = Trackpoint(LatLon(1.23, 3.45), Instant.now().toEpochMilli(), 1f, 1f)
+
+    private val allowEverything = mock<UserLoginSource>()
+    private val allowNothing = mock<UserLoginSource>()
+    private val anonymous = mock<UserLoginSource>()
+
+    init {
+        on(allowEverything.accessToken).thenReturn(OsmDevApi.ALLOW_EVERYTHING_TOKEN)
+        on(allowNothing.accessToken).thenReturn(OsmDevApi.ALLOW_NOTHING_TOKEN)
+        on(anonymous.accessToken).thenReturn(null)
+    }
+
+    @Test fun `throws exception on insufficient privileges`(): Unit = runBlocking {
+        assertFailsWith<AuthorizationException> { client(anonymous).create(listOf(trackpoint)) }
+        assertFailsWith<AuthorizationException> { client(allowNothing).create(listOf(trackpoint)) }
+    }
+
+    @Test fun `create works without error`(): Unit = runBlocking {
+        client(allowEverything).create(listOf(trackpoint))
+    }
+
+    private fun client(userLoginSource: UserLoginSource) =
+        TracksApiClient(HttpClient(), OsmDevApi.URL, userLoginSource, TracksSerializer())
+}
+
diff --git a/app/src/test/java/de/westnordost/streetcomplete/data/osmtracks/TracksSerializerTest.kt b/app/src/test/java/de/westnordost/streetcomplete/data/osmtracks/TracksSerializerTest.kt
new file mode 100644
index 00000000000..3dd13a33e20
--- /dev/null
+++ b/app/src/test/java/de/westnordost/streetcomplete/data/osmtracks/TracksSerializerTest.kt
@@ -0,0 +1,52 @@
+package de.westnordost.streetcomplete.data.osmtracks
+
+import de.westnordost.streetcomplete.ApplicationConstants
+import de.westnordost.streetcomplete.data.osm.mapdata.LatLon
+import kotlinx.datetime.Instant
+import kotlin.test.Test
+import kotlin.test.assertEquals
+
+class TracksSerializerTest {
+
+    @Test
+    fun `serialize to xml`() {
+        val gpx = """
+            <gpx xmlns="http://www.topografix.com/GPX/1/0" version="1.0" creator="${ApplicationConstants.USER_AGENT}">
+            <trk>
+            <trkseg>
+            <trkpt lat="12.34" lon="56.78">
+            <time>2024-06-05T09:51:14Z</time>
+            <ele>1.23</ele>
+            <hdop>4.56</hdop>
+            </trkpt>
+            <trkpt lat="12.3999" lon="56.7999">
+            <time>2024-06-05T09:51:15Z</time>
+            <ele>1.24</ele>
+            <hdop>4.57</hdop>
+            </trkpt>
+            </trkseg>
+            </trk>
+            </gpx>
+        """
+
+        val track = listOf(
+            Trackpoint(
+                position = LatLon(12.34, 56.78),
+                time = Instant.parse("2024-06-05T09:51:14Z").toEpochMilliseconds(),
+                accuracy = 4.56f,
+                elevation = 1.23f
+            ),
+            Trackpoint(
+                position = LatLon(12.3999, 56.7999),
+                time = Instant.parse("2024-06-05T09:51:15Z").toEpochMilliseconds(),
+                accuracy = 4.57f,
+                elevation = 1.24f
+            ),
+        )
+
+        assertEquals(
+            gpx.replace(Regex("[\n\r] *"), ""),
+            TracksSerializer().serialize(track)
+        )
+    }
+}
diff --git a/app/src/test/java/de/westnordost/streetcomplete/data/user/UserApiClientTest.kt b/app/src/test/java/de/westnordost/streetcomplete/data/user/UserApiClientTest.kt
new file mode 100644
index 00000000000..74b514d6ae6
--- /dev/null
+++ b/app/src/test/java/de/westnordost/streetcomplete/data/user/UserApiClientTest.kt
@@ -0,0 +1,55 @@
+package de.westnordost.streetcomplete.data.user
+
+import de.westnordost.streetcomplete.data.AuthorizationException
+import de.westnordost.streetcomplete.testutils.OsmDevApi
+import de.westnordost.streetcomplete.testutils.mock
+import de.westnordost.streetcomplete.testutils.on
+import io.ktor.client.HttpClient
+import kotlinx.coroutines.runBlocking
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertFailsWith
+import kotlin.test.assertNotNull
+
+// other than some other APIs we are speaking to, we do not control the OSM API, so I think it is
+// more effective to test with the official test API instead of mocking some imagined server
+// response
+class UserApiClientTest {
+    private val allowEverything = mock<UserLoginSource>()
+    private val allowNothing = mock<UserLoginSource>()
+    private val anonymous = mock<UserLoginSource>()
+
+    init {
+        on(allowEverything.accessToken).thenReturn(OsmDevApi.ALLOW_EVERYTHING_TOKEN)
+        on(allowNothing.accessToken).thenReturn(OsmDevApi.ALLOW_NOTHING_TOKEN)
+        on(anonymous.accessToken).thenReturn(null)
+    }
+
+    @Test
+    fun get(): Unit = runBlocking {
+        val info = client(anonymous).get(3625)
+
+        assertNotNull(info)
+        assertEquals(3625, info.id)
+        assertEquals("westnordost", info.displayName)
+        assertNotNull(info.profileImageUrl)
+    }
+
+    @Test
+    fun getMine(): Unit = runBlocking {
+        val info = client(allowEverything).getMine()
+
+        assertNotNull(info)
+        assertEquals(3625, info.id)
+        assertEquals("westnordost", info.displayName)
+        assertNotNull(info.profileImageUrl)
+    }
+
+    @Test
+    fun `getMine fails when not logged in`(): Unit = runBlocking {
+        assertFailsWith<AuthorizationException> { client(anonymous).getMine() }
+    }
+
+    private fun client(userLoginSource: UserLoginSource) =
+        UserApiClient(HttpClient(), OsmDevApi.URL, userLoginSource, UserApiParser())
+}
diff --git a/app/src/test/java/de/westnordost/streetcomplete/data/user/UserApiParserTest.kt b/app/src/test/java/de/westnordost/streetcomplete/data/user/UserApiParserTest.kt
new file mode 100644
index 00000000000..f9cdb88a201
--- /dev/null
+++ b/app/src/test/java/de/westnordost/streetcomplete/data/user/UserApiParserTest.kt
@@ -0,0 +1,61 @@
+package de.westnordost.streetcomplete.data.user
+
+import kotlin.test.Test
+import kotlin.test.assertEquals
+
+class UserApiParserTest {
+
+    @Test
+    fun `parse minimum user info`() {
+        val xml = """<osm><user display_name="Max Muster" id="1234"/></osm>""".trimIndent()
+
+        assertEquals(
+            UserInfo(
+                id = 1234,
+                displayName = "Max Muster",
+                profileImageUrl = null
+            ),
+            UserApiParser().parseUsers(xml).single()
+        )
+    }
+
+    @Test
+    fun `parse full user info`() {
+        val xml = """
+            <osm version="0.6" generator="OpenStreetMap server" copyright="OpenStreetMap and contributors" attribution="http://www.openstreetmap.org/copyright" license="http://opendatacommons.org/licenses/odbl/1-0/">
+              <user display_name="Max Muster" account_created="2006-07-21T19:28:26Z" id="1234">
+                <contributor-terms agreed="true" pd="true"/>
+                <img href="https://www.openstreetmap.org/attachments/users/images/000/000/1234/original/someLongURLOrOther.JPG"/>
+                <roles></roles>
+                <changesets count="4182"/>
+                <traces count="513"/>
+                <blocks>
+                  <received count="0" active="0"/>
+                </blocks>
+                <home lat="49.4733718952806" lon="8.89285988577866" zoom="3"/>
+                <description>The description of your profile</description>
+                <languages>
+                  <lang>de-DE</lang>
+                  <lang>de</lang>
+                  <lang>en-US</lang>
+                  <lang>en</lang>
+                </languages>
+                <messages>
+                  <received count="1" unread="0"/>
+                  <sent count="0"/>
+                </messages>
+              </user>
+            </osm>
+        """.trimIndent()
+
+        assertEquals(
+            UserInfo(
+                id = 1234,
+                displayName = "Max Muster",
+                profileImageUrl = "https://www.openstreetmap.org/attachments/users/images/000/000/1234/original/someLongURLOrOther.JPG",
+                unreadMessagesCount = 0,
+            ),
+            UserApiParser().parseUsers(xml).single()
+        )
+    }
+}
diff --git a/app/src/test/java/de/westnordost/streetcomplete/data/user/oauth/OAuthAuthorizationTest.kt b/app/src/test/java/de/westnordost/streetcomplete/data/user/oauth/OAuthAuthorizationTest.kt
index c27e5297f04..22600cafa47 100644
--- a/app/src/test/java/de/westnordost/streetcomplete/data/user/oauth/OAuthAuthorizationTest.kt
+++ b/app/src/test/java/de/westnordost/streetcomplete/data/user/oauth/OAuthAuthorizationTest.kt
@@ -1,5 +1,6 @@
 package de.westnordost.streetcomplete.data.user.oauth
 
+import de.westnordost.streetcomplete.data.ConnectionException
 import io.ktor.client.HttpClient
 import io.ktor.client.engine.mock.MockEngine
 import io.ktor.client.engine.mock.respondError
@@ -82,40 +83,40 @@ class OAuthAuthorizationTest {
 
     @Test fun `extractAuthorizationCode fails with useful error messages`(): Unit = runBlocking {
         val oauth = createOAuth()
-        val service = OAuthService(HttpClient(MockEngine { respondOk() }))
+        val service = OAuthApiClient(HttpClient(MockEngine { respondOk() }))
 
         // server did not respond correctly with "error"
-        assertFailsWith<OAuthConnectionException> {
-            service.retrieveAccessToken(oauth, "localhost://oauth?e=something")
+        assertFailsWith<ConnectionException> {
+            service.getAccessToken(oauth, "localhost://oauth?e=something")
         }
 
         try {
-            service.retrieveAccessToken(oauth, "localhost://oauth?error=hey%2Bwhat%27s%2Bup")
+            service.getAccessToken(oauth, "localhost://oauth?error=hey%2Bwhat%27s%2Bup")
         } catch (e: OAuthException) {
             assertEquals("hey what's up", e.message)
         }
 
         try {
-            service.retrieveAccessToken(oauth, "localhost://oauth?error=A%21&error_description=B%21")
+            service.getAccessToken(oauth, "localhost://oauth?error=A%21&error_description=B%21")
         } catch (e: OAuthException) {
             assertEquals("A!: B!", e.message)
         }
 
         try {
-            service.retrieveAccessToken(oauth, "localhost://oauth?error=A%21&error_uri=http%3A%2F%2Fabc.de")
+            service.getAccessToken(oauth, "localhost://oauth?error=A%21&error_uri=http%3A%2F%2Fabc.de")
         } catch (e: OAuthException) {
             assertEquals("A! (see http://abc.de)", e.message)
         }
 
         try {
-            service.retrieveAccessToken(oauth, "localhost://oauth?error=A%21&error_description=B%21&error_uri=http%3A%2F%2Fabc.de")
+            service.getAccessToken(oauth, "localhost://oauth?error=A%21&error_description=B%21&error_uri=http%3A%2F%2Fabc.de")
         } catch (e: OAuthException) {
             assertEquals("A!: B! (see http://abc.de)", e.message)
         }
     }
 
     @Test fun extractAuthorizationCode() = runBlocking {
-        val service = OAuthService(HttpClient(MockEngine { request ->
+        val service = OAuthApiClient(HttpClient(MockEngine { request ->
             if (request.url.parameters["code"] == "my code") {
                 respondOk("""{
                     "access_token": "TOKEN",
@@ -130,20 +131,20 @@ class OAuthAuthorizationTest {
 
         assertEquals(
             AccessTokenResponse("TOKEN", listOf("A", "B", "C")),
-            service.retrieveAccessToken(oauth, "localhost://oauth?code=my%20code")
+            service.getAccessToken(oauth, "localhost://oauth?code=my%20code")
         )
     }
 
     @Test fun `retrieveAccessToken throws OAuthConnectionException with invalid response token_type`(): Unit = runBlocking {
-        val service = OAuthService(HttpClient(MockEngine { respondOk("""{
+        val service = OAuthApiClient(HttpClient(MockEngine { respondOk("""{
             "access_token": "TOKEN",
             "token_type": "an_unusual_token_type",
             "scope": "A B C"
         }""")
         }))
 
-        val exception = assertFailsWith<OAuthConnectionException> {
-            service.retrieveAccessToken(dummyOAuthAuthorization(), "localhost://oauth?code=code")
+        val exception = assertFailsWith<ConnectionException> {
+            service.getAccessToken(dummyOAuthAuthorization(), "localhost://oauth?code=code")
         }
 
         assertEquals(
@@ -153,7 +154,7 @@ class OAuthAuthorizationTest {
     }
 
     @Test fun `retrieveAccessToken throws OAuthException when error response`(): Unit = runBlocking {
-        val service = OAuthService(HttpClient(MockEngine { respondError(
+        val service = OAuthApiClient(HttpClient(MockEngine { respondError(
             HttpStatusCode.BadRequest, """{
                 "error": "Missing auth code",
                 "error_description": "Please specify a code",
@@ -162,7 +163,7 @@ class OAuthAuthorizationTest {
         ) }))
 
         val exception = assertFailsWith<OAuthException> {
-            service.retrieveAccessToken(dummyOAuthAuthorization(), "localhost://oauth?code=code")
+            service.getAccessToken(dummyOAuthAuthorization(), "localhost://oauth?code=code")
         }
 
         assertEquals("Missing auth code", exception.error)
@@ -181,7 +182,7 @@ class OAuthAuthorizationTest {
             "scheme://there"
         )
 
-        assertFails { OAuthService(HttpClient(mockEngine)).retrieveAccessToken(auth, "scheme://there?code=C0D3") }
+        assertFails { OAuthApiClient(HttpClient(mockEngine)).getAccessToken(auth, "scheme://there?code=C0D3") }
 
         val expectedParams = ParametersBuilder()
         expectedParams.append("grant_type", "authorization_code")
@@ -199,7 +200,7 @@ class OAuthAuthorizationTest {
         val mockEngine = MockEngine { respondOk() }
 
         assertFails {
-            OAuthService(HttpClient(mockEngine)).retrieveAccessToken(dummyOAuthAuthorization(), "localhost://oauth?code=code")
+            OAuthApiClient(HttpClient(mockEngine)).getAccessToken(dummyOAuthAuthorization(), "localhost://oauth?code=code")
         }
 
         val expectedHeaders = HeadersBuilder()
diff --git a/app/src/test/java/de/westnordost/streetcomplete/data/user/statistics/StatisticsApiClientTest.kt b/app/src/test/java/de/westnordost/streetcomplete/data/user/statistics/StatisticsApiClientTest.kt
new file mode 100644
index 00000000000..4549aa45b0f
--- /dev/null
+++ b/app/src/test/java/de/westnordost/streetcomplete/data/user/statistics/StatisticsApiClientTest.kt
@@ -0,0 +1,57 @@
+package de.westnordost.streetcomplete.data.user.statistics
+
+import de.westnordost.streetcomplete.data.ApiClientException
+import de.westnordost.streetcomplete.testutils.mock
+import de.westnordost.streetcomplete.testutils.on
+import io.ktor.client.HttpClient
+import io.ktor.client.engine.mock.MockEngine
+import io.ktor.client.engine.mock.respondBadRequest
+import io.ktor.client.engine.mock.respondOk
+import kotlinx.coroutines.runBlocking
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertFailsWith
+
+class StatisticsApiClientTest {
+    private val statisticsParser: StatisticsParser = mock()
+
+    private val validResponseMockEngine = MockEngine { respondOk("simple response") }
+
+    @Test fun `download parses all statistics`() = runBlocking {
+        val client = StatisticsApiClient(HttpClient(validResponseMockEngine), "", statisticsParser)
+        val stats = Statistics(
+            types = listOf(),
+            countries = listOf(),
+            rank = 2,
+            daysActive = 100,
+            currentWeekRank = 50,
+            currentWeekTypes = listOf(),
+            currentWeekCountries = listOf(),
+            activeDates = listOf(),
+            activeDatesRange = 100,
+            isAnalyzing = false,
+            lastUpdate = 10
+        )
+        on(statisticsParser.parse("simple response")).thenReturn(stats)
+        assertEquals(stats, client.get(100))
+    }
+
+    @Test fun `download throws Exception for a 400 response`(): Unit = runBlocking {
+        val mockEngine = MockEngine { _ -> respondBadRequest() }
+        val client = StatisticsApiClient(HttpClient(mockEngine), "", statisticsParser)
+        assertFailsWith<ApiClientException> { client.get(100) }
+    }
+
+    @Test fun `download constructs request URL`() = runBlocking {
+        StatisticsApiClient(
+            HttpClient(validResponseMockEngine),
+            "https://example.com/stats/",
+            statisticsParser
+        ).get(100)
+
+        assertEquals(
+            "https://example.com/stats/?user_id=100",
+            validResponseMockEngine.requestHistory[0].url.toString()
+        )
+    }
+}
diff --git a/app/src/test/java/de/westnordost/streetcomplete/data/user/statistics/StatisticsDownloaderTest.kt b/app/src/test/java/de/westnordost/streetcomplete/data/user/statistics/StatisticsDownloaderTest.kt
deleted file mode 100644
index 03ec69519c8..00000000000
--- a/app/src/test/java/de/westnordost/streetcomplete/data/user/statistics/StatisticsDownloaderTest.kt
+++ /dev/null
@@ -1,44 +0,0 @@
-package de.westnordost.streetcomplete.data.user.statistics
-
-import de.westnordost.streetcomplete.testutils.mock
-import de.westnordost.streetcomplete.testutils.on
-import io.ktor.client.HttpClient
-import io.ktor.client.engine.mock.MockEngine
-import io.ktor.client.engine.mock.respondBadRequest
-import io.ktor.client.engine.mock.respondOk
-import kotlinx.coroutines.runBlocking
-import kotlin.test.Test
-import kotlin.test.assertEquals
-import kotlin.test.assertFails
-
-class StatisticsDownloaderTest {
-    private val statisticsParser: StatisticsParser = mock()
-
-    private val validResponseMockEngine = MockEngine { _ -> respondOk("simple response") }
-
-    @Test fun `download parses all statistics`() = runBlocking {
-        val stats = Statistics(types = listOf(), countries = listOf(), rank = 2, daysActive = 100, currentWeekRank = 50, currentWeekTypes = listOf(), currentWeekCountries = listOf(), activeDates = listOf(), activeDatesRange = 100, isAnalyzing = false, lastUpdate = 10)
-        on(statisticsParser.parse("simple response")).thenReturn(stats)
-        assertEquals(stats, StatisticsDownloader(HttpClient(validResponseMockEngine), "", statisticsParser).download(100))
-    }
-
-    @Test fun `download throws Exception for a 400 response`() = runBlocking {
-        val mockEngine = MockEngine { _ -> respondBadRequest() }
-        val exception = assertFails { StatisticsDownloader(HttpClient(mockEngine), "", statisticsParser).download(100) }
-
-        assertEquals(
-            "Client request(GET http://localhost/?user_id=100) invalid: 400 Bad Request. Text: \"Bad Request\"",
-            exception.message
-        )
-    }
-
-    @Test fun `download constructs request URL`() = runBlocking {
-        StatisticsDownloader(
-            HttpClient(validResponseMockEngine),
-            "https://example.com/stats/",
-            statisticsParser
-        ).download(100)
-
-        assertEquals("https://example.com/stats/?user_id=100", validResponseMockEngine.requestHistory[0].url.toString())
-    }
-}
diff --git a/app/src/test/java/de/westnordost/streetcomplete/testutils/OsmDevApi.kt b/app/src/test/java/de/westnordost/streetcomplete/testutils/OsmDevApi.kt
new file mode 100644
index 00000000000..8e7f24db9a9
--- /dev/null
+++ b/app/src/test/java/de/westnordost/streetcomplete/testutils/OsmDevApi.kt
@@ -0,0 +1,9 @@
+package de.westnordost.streetcomplete.testutils
+
+object OsmDevApi {
+    const val URL = "https://master.apis.dev.openstreetmap.org/api/0.6/"
+
+    // copied straight from osmapi (Java), because, why not
+    const val ALLOW_EVERYTHING_TOKEN = "qzaxWiG2tprF1IfEcwf4-mn7Al4f2lsM3CNrvGEaIL0"
+    const val ALLOW_NOTHING_TOKEN = "fp2SjHKQ55rSdI2x4FN_s0wNUh67dgNbf9x3WdjCa5Y"
+}
diff --git a/app/src/test/resources/image_uploader/empty.jpg b/app/src/test/resources/image_uploader/empty.jpg
deleted file mode 100644
index e69de29bb2d..00000000000
diff --git a/app/src/test/resources/image_uploader/invalid.jpg b/app/src/test/resources/image_uploader/invalid.jpg
deleted file mode 100644
index 9977a2836c1..00000000000
--- a/app/src/test/resources/image_uploader/invalid.jpg
+++ /dev/null
@@ -1,1 +0,0 @@
-invalid
diff --git a/app/src/test/resources/image_uploader/ioexception.jpg b/app/src/test/resources/image_uploader/ioexception.jpg
deleted file mode 100644
index abfff0458b5..00000000000
--- a/app/src/test/resources/image_uploader/ioexception.jpg
+++ /dev/null
@@ -1,1 +0,0 @@
-ioexception
diff --git a/app/src/test/resources/image_uploader/valid.jpg b/app/src/test/resources/image_uploader/valid.jpg
deleted file mode 100644
index 1e2466dfadd..00000000000
--- a/app/src/test/resources/image_uploader/valid.jpg
+++ /dev/null
@@ -1,1 +0,0 @@
-valid