From b2246d5a8b0516ccce3dd00c950936cc9a40bf0a Mon Sep 17 00:00:00 2001 From: Rajesh Kantipudi <44539669+iamrajeshk@users.noreply.github.com> Date: Thu, 4 Jun 2026 13:17:38 +0530 Subject: [PATCH 1/2] Refactor image upload logic to use access token from preferences and improve error handling --- .gitignore | 1 + .../streetcomplete/ApplicationModule.kt | 14 ++ .../quests/AbstractOsmQuestForm.kt | 153 +++++++++--------- .../streetcomplete/ApplicationConstants.kt | 2 +- .../data/preferences/Preferences.kt | 7 + 5 files changed, 97 insertions(+), 80 deletions(-) diff --git a/.gitignore b/.gitignore index 0dcd83d0e62..ba3cf311e5a 100644 --- a/.gitignore +++ b/.gitignore @@ -24,3 +24,4 @@ captures !*.xcworkspace/contents.xcworkspacedata **/xcshareddata/WorkspaceSettings.xcsettings xcuserdata +node_modules diff --git a/app/src/androidMain/kotlin/de/westnordost/streetcomplete/ApplicationModule.kt b/app/src/androidMain/kotlin/de/westnordost/streetcomplete/ApplicationModule.kt index 0a11bbfd5cd..3168ee2666b 100644 --- a/app/src/androidMain/kotlin/de/westnordost/streetcomplete/ApplicationModule.kt +++ b/app/src/androidMain/kotlin/de/westnordost/streetcomplete/ApplicationModule.kt @@ -22,6 +22,7 @@ import io.ktor.client.plugins.auth.providers.BearerTokens import io.ktor.client.plugins.auth.providers.bearer import io.ktor.client.plugins.compression.ContentEncoding import io.ktor.client.plugins.contentnegotiation.ContentNegotiation +import io.ktor.client.plugins.HttpRequestRetry import io.ktor.client.plugins.defaultRequest import io.ktor.client.plugins.logging.DEFAULT import io.ktor.client.plugins.logging.LogLevel @@ -133,6 +134,19 @@ val appModule = module { } } + single(named("kartaViewClient")) { + HttpClient { + install(ContentNegotiation) { + json(Json { ignoreUnknownKeys = true }) + } + install(HttpRequestRetry) { + retryOnServerErrors(maxRetries = 3) + retryOnException(maxRetries = 3, retryOnTimeout = true) + exponentialDelay() + } + } + } + single { Res } single { SystemFileSystem } single { DefaultResourceProvider(androidContext(), get()) } diff --git a/app/src/androidMain/kotlin/de/westnordost/streetcomplete/quests/AbstractOsmQuestForm.kt b/app/src/androidMain/kotlin/de/westnordost/streetcomplete/quests/AbstractOsmQuestForm.kt index d2fd6984314..78b46353f94 100644 --- a/app/src/androidMain/kotlin/de/westnordost/streetcomplete/quests/AbstractOsmQuestForm.kt +++ b/app/src/androidMain/kotlin/de/westnordost/streetcomplete/quests/AbstractOsmQuestForm.kt @@ -28,7 +28,7 @@ import de.westnordost.osmfeatures.Feature import de.westnordost.osmfeatures.FeatureDictionary import de.westnordost.streetcomplete.R import de.westnordost.streetcomplete.data.karta_view.domain.model.CreateSequenceResponse -import de.westnordost.streetcomplete.data.karta_view.domain.model.ImageUploadResponse +import de.westnordost.streetcomplete.data.karta_view.domain.model.PhotoLookupResponse import de.westnordost.streetcomplete.data.location.SurveyChecker import de.westnordost.streetcomplete.data.osm.edits.AddElementEditsController import de.westnordost.streetcomplete.data.osm.edits.ElementEditAction @@ -53,6 +53,7 @@ import de.westnordost.streetcomplete.data.quest.Quest import de.westnordost.streetcomplete.data.quest.QuestKey import de.westnordost.streetcomplete.data.visiblequests.HideQuestController import de.westnordost.streetcomplete.data.visiblequests.QuestsHiddenController +import de.westnordost.streetcomplete.data.preferences.Preferences import de.westnordost.streetcomplete.osm.applyReplacePlaceTo import de.westnordost.streetcomplete.quests.sidewalk_long_form.AddGenericLong import de.westnordost.streetcomplete.screens.main.map.Compass @@ -65,6 +66,8 @@ import io.ktor.client.HttpClient import io.ktor.client.call.body import io.ktor.client.request.forms.MultiPartFormDataContent import io.ktor.client.request.forms.formData +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.Headers @@ -92,7 +95,8 @@ abstract class AbstractOsmQuestForm : AbstractQuestForm(), IsShowingQuestDeta private val surveyChecker: SurveyChecker by inject() protected val featureDictionary: FeatureDictionary get() = featureDictionaryLazy.value - private val httpClient: HttpClient by inject() + private val httpClient: HttpClient by inject(named("kartaViewClient")) + private val prefs: Preferences by inject() private lateinit var cameraLauncher: ActivityResultLauncher // only used for testing / only used for ShowQuestFormsScreen! Found no better way to do this @@ -438,30 +442,42 @@ abstract class AbstractOsmQuestForm : AbstractQuestForm(), IsShowingQuestDeta private fun startKartViewFlow(bitmap: Bitmap?, displayedLocation: Location?) { viewLifecycleScope.launch { - val sequenceId = createSequence() - sequenceId?.apply { - val isUploaded = uploadImageInSequence(this, bitmap, displayedLocation) - if (isUploaded.first) { - //https://storage13.openstreetcam.org/files/photo/2024/11/19/lth/10206921_53966_673c7da2672cf.jpg - //After storage13 add .openstreetcam.org in the url - // FInd where storage13 is in the first - val first = isUploaded.second?.first?.replace( - "storage13", - "storage13.openstreetcam.org" - ) - val url = "https://${first}/lth/${isUploaded.second?.second}" - onImageUrlReceived(url) - closeSequence(this) + val sequenceId = createSequence() ?: return@launch + val uploaded = uploadImageInSequence(sequenceId, bitmap, displayedLocation) + if (uploaded) { + val lthUrl = getPhotoLthUrl(sequenceId, sequenceIndex = 1) + if (lthUrl != null) { + Log.d("KartViewFlow", lthUrl) + onImageUrlReceived(lthUrl) } else { hideProgressbar() - Log.e("KartViewFlow", "Image upload failed") + Log.e("KartViewFlow", "Failed to retrieve photo URL") } + closeSequence(sequenceId) + } else { + hideProgressbar() + Log.e("KartViewFlow", "Image upload failed") } } } + private suspend fun getPhotoLthUrl(sequenceId: String, sequenceIndex: Int): String? { + val token = prefs.kartaViewAccessToken + val response = httpClient.get("https://api.openstreetcam.org/2.0/photo/") { + parameter("access_token", token) + parameter("sequenceId", sequenceId) + parameter("sequenceIndex", sequenceIndex) + } + if (response.status == HttpStatusCode.OK) { + val photoResponse = response.body() + return photoResponse.result.data.firstOrNull()?.imageLthUrl + } + Log.e("KartViewFlow", "Photo lookup failed: ${response.status}") + return null + } + private suspend fun closeSequence(sequenceId: String) { - val token = "96aca5c4b80709fc6d9aced613b51905c0fbc37870640d7bdabede269165bde7" + val token = prefs.kartaViewAccessToken val response = httpClient.post("https://api.openstreetcam.org/1.0/sequence/finished-uploading/") { setBody(MultiPartFormDataContent(formData { @@ -485,68 +501,47 @@ abstract class AbstractOsmQuestForm : AbstractQuestForm(), IsShowingQuestDeta sequenceId: String, bitmap: Bitmap?, location: Location?, - ): Pair?> { - - val displayedLocation = listener?.displayedMapLocation - if (displayedLocation != null) { - bitmap?.let { - val byteArrayOutputStream = ByteArrayOutputStream() - it.compress(Bitmap.CompressFormat.JPEG, 100, byteArrayOutputStream) - val byteArray = byteArrayOutputStream.toByteArray() - - val response = httpClient.post("https://api.openstreetcam.org/1.0/photo/") { - setBody(MultiPartFormDataContent(formData { - append( - "access_token", - "96aca5c4b80709fc6d9aced613b51905c0fbc37870640d7bdabede269165bde7" - ) - append("sequenceId", sequenceId) - append("sequenceIndex", 1) - append( - "coordinate", - "${displayedLocation.latitude},${displayedLocation.longitude}" - ) - var finalBearing = 0.0f - finalBearing = - if (displayedLocation.hasBearing() && displayedLocation.bearing != 0f) { - displayedLocation.bearing - } else { - compassBearing.toFloat() - } - append("headers", finalBearing.toInt().toString()) - append("photo", byteArray, Headers.build { - append(HttpHeaders.ContentType, "image/jpeg") - append( - HttpHeaders.ContentDisposition, - "filename=\"wework-kartaview.jpg\"" - ) - }) - })) - } + ): Boolean { + val displayedLocation = listener?.displayedMapLocation ?: return false + bitmap ?: return false - if (response.status == HttpStatusCode.OK) { - val uploadResponse = response.body() - val pair = - Pair(uploadResponse.osv.photo.path, uploadResponse.osv.photo.photoName) - showSnackBar( - "Image Uploaded Successfully", - view, - requireActivity() as ComponentActivity - ) - Log.d("UploadImage", "Image uploaded successfully") - return Pair(true, pair) - } else { - Log.e("UploadImage", "Image upload failed: ${response.status}") - showSnackBar( - "Failed to upload image to KartaView. Please try again later " + response.status, - view, requireActivity() as ComponentActivity - ) - hideProgressbar() - return Pair(false, null) - } - } + val byteArrayOutputStream = ByteArrayOutputStream() + bitmap.compress(Bitmap.CompressFormat.JPEG, 100, byteArrayOutputStream) + val byteArray = byteArrayOutputStream.toByteArray() + + val finalBearing = if (displayedLocation.hasBearing() && displayedLocation.bearing != 0f) { + displayedLocation.bearing + } else { + compassBearing.toFloat() + } + + val response = httpClient.post("https://api.openstreetcam.org/1.0/photo/") { + setBody(MultiPartFormDataContent(formData { + append("access_token", prefs.kartaViewAccessToken) + append("sequenceId", sequenceId) + append("sequenceIndex", 1) + append("coordinate", "${displayedLocation.latitude},${displayedLocation.longitude}") + append("headers", finalBearing.toInt().toString()) + append("photo", byteArray, Headers.build { + append(HttpHeaders.ContentType, "image/jpeg") + append(HttpHeaders.ContentDisposition, "filename=\"wework-kartaview.jpg\"") + }) + })) + } + + return if (response.status == HttpStatusCode.OK) { + showSnackBar("Image Uploaded Successfully", view, requireActivity() as ComponentActivity) + Log.d("UploadImage", "Image uploaded successfully") + true + } else { + Log.e("UploadImage", "Image upload failed: ${response.status}") + showSnackBar( + "Failed to upload image to KartaView. Please try again later " + response.status, + view, requireActivity() as ComponentActivity + ) + hideProgressbar() + false } - return Pair(false, null) } private fun showSnackBar(message: String, view: View?, componentActivity: ComponentActivity) { @@ -556,7 +551,7 @@ abstract class AbstractOsmQuestForm : AbstractQuestForm(), IsShowingQuestDeta } private suspend fun createSequence(): String? { - val token = "96aca5c4b80709fc6d9aced613b51905c0fbc37870640d7bdabede269165bde7" + val token = prefs.kartaViewAccessToken val response = httpClient.post("https://api.openstreetcam.org/1.0/sequence/") { setBody(MultiPartFormDataContent(formData { append("access_token", token) diff --git a/app/src/commonMain/kotlin/de/westnordost/streetcomplete/ApplicationConstants.kt b/app/src/commonMain/kotlin/de/westnordost/streetcomplete/ApplicationConstants.kt index 2835887f654..4260bf1353f 100644 --- a/app/src/commonMain/kotlin/de/westnordost/streetcomplete/ApplicationConstants.kt +++ b/app/src/commonMain/kotlin/de/westnordost/streetcomplete/ApplicationConstants.kt @@ -84,7 +84,7 @@ object ApplicationConstants { const val ATTACH_PHOTO_MAX_SIZE = 1920 // Full HD // where to send the error reports to - const val ERROR_REPORTS_EMAIL = "streetcomplete_errors@westnordost.de" + const val ERROR_REPORTS_EMAIL = "helpdesk@tdei.us" /** Which relation types to drop already during download, before persisting. This is a * performance improvement. Working properly with relations means we have to have it as diff --git a/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/preferences/Preferences.kt b/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/preferences/Preferences.kt index 6ec0562751f..d2228f9e1f3 100644 --- a/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/preferences/Preferences.kt +++ b/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/preferences/Preferences.kt @@ -212,6 +212,10 @@ class Preferences(private val prefs: ObservableSettings) { } get() = prefs.getBoolean("${environment}_$LOW_BANDWIDTH_MODE_ENABLED", false) + var kartaViewAccessToken: String + set(value) { prefs.putString(KARTAVIEW_ACCESS_TOKEN, value) } + get() = prefs.getString(KARTAVIEW_ACCESS_TOKEN, DEFAULT_KARTAVIEW_ACCESS_TOKEN) + var isFollowModeEnabled: Boolean set(value) { prefs.putBoolean("${environment}_$FOLLOW_MODE_ENABLED", value) @@ -440,5 +444,8 @@ class Preferences(private val prefs: ObservableSettings) { private const val LOW_BANDWIDTH_MODE_ENABLED = "low.bandwidth.enabled" private const val FOLLOW_MODE_ENABLED = "follow.mode.enabled" private const val DEBUG_MODE_ENABLED = "debug.mode.enabled" + private const val KARTAVIEW_ACCESS_TOKEN = "kartaview.access_token" + private const val DEFAULT_KARTAVIEW_ACCESS_TOKEN = + "96aca5c4b80709fc6d9aced613b51905c0fbc37870640d7bdabede269165bde7" } } From 74379b74c729626284a5913554f39d30801d219f Mon Sep 17 00:00:00 2001 From: Rajesh Kantipudi <44539669+iamrajeshk@users.noreply.github.com> Date: Fri, 5 Jun 2026 15:40:46 +0530 Subject: [PATCH 2/2] Enhance image upload flow by adding error handling and closing sequence properly; update input type for decimal values --- .../domain/model/PhotoLookupResponse.kt | 18 ++++++++++++++++++ .../quests/AbstractOsmQuestForm.kt | 6 ++++-- .../sidewalk_long_form/data/LongFormAdapter.kt | 6 +++--- .../res/layout/cell_long_form_item_input.xml | 2 +- 4 files changed, 26 insertions(+), 6 deletions(-) create mode 100644 app/src/androidMain/kotlin/de/westnordost/streetcomplete/data/karta_view/domain/model/PhotoLookupResponse.kt diff --git a/app/src/androidMain/kotlin/de/westnordost/streetcomplete/data/karta_view/domain/model/PhotoLookupResponse.kt b/app/src/androidMain/kotlin/de/westnordost/streetcomplete/data/karta_view/domain/model/PhotoLookupResponse.kt new file mode 100644 index 00000000000..f87a08deff4 --- /dev/null +++ b/app/src/androidMain/kotlin/de/westnordost/streetcomplete/data/karta_view/domain/model/PhotoLookupResponse.kt @@ -0,0 +1,18 @@ +package de.westnordost.streetcomplete.data.karta_view.domain.model + +import kotlinx.serialization.Serializable + +@Serializable +data class PhotoLookupResponse( + val result: PhotoLookupResult? = null +) + +@Serializable +data class PhotoLookupResult( + val data: List = emptyList() +) + +@Serializable +data class PhotoLookup( + val imageLthUrl: String +) diff --git a/app/src/androidMain/kotlin/de/westnordost/streetcomplete/quests/AbstractOsmQuestForm.kt b/app/src/androidMain/kotlin/de/westnordost/streetcomplete/quests/AbstractOsmQuestForm.kt index 78b46353f94..6d217ea7ab9 100644 --- a/app/src/androidMain/kotlin/de/westnordost/streetcomplete/quests/AbstractOsmQuestForm.kt +++ b/app/src/androidMain/kotlin/de/westnordost/streetcomplete/quests/AbstractOsmQuestForm.kt @@ -445,6 +445,7 @@ abstract class AbstractOsmQuestForm : AbstractQuestForm(), IsShowingQuestDeta val sequenceId = createSequence() ?: return@launch val uploaded = uploadImageInSequence(sequenceId, bitmap, displayedLocation) if (uploaded) { + closeSequence(sequenceId) val lthUrl = getPhotoLthUrl(sequenceId, sequenceIndex = 1) if (lthUrl != null) { Log.d("KartViewFlow", lthUrl) @@ -452,11 +453,12 @@ abstract class AbstractOsmQuestForm : AbstractQuestForm(), IsShowingQuestDeta } else { hideProgressbar() Log.e("KartViewFlow", "Failed to retrieve photo URL") + showSnackBar("Failed to retrieve photo URL. Please try again later.", view, requireActivity() as ComponentActivity) } - closeSequence(sequenceId) } else { hideProgressbar() Log.e("KartViewFlow", "Image upload failed") + showSnackBar("Image upload failed. Please try again later.", view, requireActivity() as ComponentActivity) } } } @@ -470,7 +472,7 @@ abstract class AbstractOsmQuestForm : AbstractQuestForm(), IsShowingQuestDeta } if (response.status == HttpStatusCode.OK) { val photoResponse = response.body() - return photoResponse.result.data.firstOrNull()?.imageLthUrl + return photoResponse.result?.data?.firstOrNull()?.imageLthUrl } Log.e("KartViewFlow", "Photo lookup failed: ${response.status}") return null diff --git a/app/src/androidMain/kotlin/de/westnordost/streetcomplete/quests/sidewalk_long_form/data/LongFormAdapter.kt b/app/src/androidMain/kotlin/de/westnordost/streetcomplete/quests/sidewalk_long_form/data/LongFormAdapter.kt index 5d636688eae..d23bc089a90 100644 --- a/app/src/androidMain/kotlin/de/westnordost/streetcomplete/quests/sidewalk_long_form/data/LongFormAdapter.kt +++ b/app/src/androidMain/kotlin/de/westnordost/streetcomplete/quests/sidewalk_long_form/data/LongFormAdapter.kt @@ -155,12 +155,12 @@ class LongFormAdapter(val cameraIntent: () -> Unit) : override fun afterTextChanged(s: Editable?) { val text = s.toString() if (text.isNotBlank()) { - val number = text.toLongOrNull() + val number = text.toFloatOrNull() if (number == null) { textInputLayout?.error = "Invalid number" - } else if (number < (minValue?.toLong() ?: 0L)) { + } else if (number < (minValue?.toFloat() ?: 0F)) { textInputLayout?.error = "Value should be greater than $minValue" - } else if (number > maxValue.toLong()) { + } else if (number > maxValue.toFloat()) { textInputLayout?.error = "Value should be less than $maxValue" } else { textInputLayout?.error = null diff --git a/app/src/androidMain/res/layout/cell_long_form_item_input.xml b/app/src/androidMain/res/layout/cell_long_form_item_input.xml index ec04221633a..de2d194851c 100644 --- a/app/src/androidMain/res/layout/cell_long_form_item_input.xml +++ b/app/src/androidMain/res/layout/cell_long_form_item_input.xml @@ -52,7 +52,7 @@ android:layout_width="match_parent" android:layout_height="wrap_content" android:hint="@string/enter_value" - android:inputType="number" /> + android:inputType="numberDecimal" />