diff --git a/.gitignore b/.gitignore index ba3cf311e5a..1a7b071ef7e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ *.ignore quest-list.csv /keystore.properties +/secrets.properties /projectFilesBackup*/ app/release *.iml diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 343a73776b5..58145241e9e 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -1,6 +1,5 @@ import com.codingfeline.buildkonfig.compiler.FieldSpec.Type.BOOLEAN import com.codingfeline.buildkonfig.compiler.FieldSpec.Type.STRING -import org.gradle.kotlin.dsl.implementation import org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi import org.jetbrains.kotlin.gradle.dsl.JvmTarget import java.io.FileInputStream @@ -9,8 +8,8 @@ import java.util.Properties /** App version name, code and flavor */ -val appVersionName = "1.2.6" -val appVersionCode = 15 +val appVersionName = "1.2.7" +val appVersionCode = 16 /** Localizations the app should be available in */ val bcp47ExportLanguages = setOf( @@ -37,7 +36,7 @@ plugins { id("org.jetbrains.kotlin.multiplatform") version "2.2.21" id("org.jetbrains.kotlin.plugin.serialization") version "2.2.21" id("org.jetbrains.kotlin.plugin.compose") version "2.2.21" - id("com.android.application") version "8.13.0" + id("com.android.application") version "9.2.1" id("org.jetbrains.compose") version "1.9.2" id("org.jetbrains.kotlinx.atomicfu") version "0.29.0" id("com.codingfeline.buildkonfig") version "0.17.1" @@ -54,6 +53,14 @@ repositories { maven { url = uri("https://www.jitpack.io") } } +/** KartaView access token: from env var, else root secrets.properties (git-ignored), else empty + * (build succeeds but KartaView photo uploads fail auth at runtime). Never commit the token. */ +val kartaViewAccessToken: String = System.getenv("KARTAVIEW_ACCESS_TOKEN") + ?: rootProject.file("secrets.properties").takeIf { it.exists() }?.let { file -> + Properties().apply { FileInputStream(file).use { load(it) } }.getProperty("kartaViewAccessToken") + } + ?: "" + buildkonfig { packageName = "de.westnordost.streetcomplete" objectName = "BuildConfig" @@ -62,6 +69,7 @@ buildkonfig { buildConfigField(BOOLEAN, "IS_FROM_MONOPOLISTIC_APP_STORE", properties["app.streetcomplete.monopolistic_app_store"]!!.toString()) buildConfigField(STRING, "VERSION_NAME", appVersionName) buildConfigField(BOOLEAN, "DEBUG", "true") + buildConfigField(STRING, "KARTAVIEW_ACCESS_TOKEN", kartaViewAccessToken) } targetConfigs { diff --git a/app/src/androidDebug/res/values/conf.xml b/app/src/androidDebug/res/values/conf.xml index e0acbd8a56a..157e85a2a43 100644 --- a/app/src/androidDebug/res/values/conf.xml +++ b/app/src/androidDebug/res/values/conf.xml @@ -1,4 +1,4 @@ - de.westnordost.streetcomplete.debug.fileprovider + net.opentoall.aviv.scoutroute.debug.fileprovider diff --git a/app/src/androidDebug/res/xml/file_paths.xml b/app/src/androidDebug/res/xml/file_paths.xml deleted file mode 100644 index b9f955c5421..00000000000 --- a/app/src/androidDebug/res/xml/file_paths.xml +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/app/src/androidMain/kotlin/de/westnordost/streetcomplete/ApplicationModule.kt b/app/src/androidMain/kotlin/de/westnordost/streetcomplete/ApplicationModule.kt index f53aef239e0..796c4498c52 100644 --- a/app/src/androidMain/kotlin/de/westnordost/streetcomplete/ApplicationModule.kt +++ b/app/src/androidMain/kotlin/de/westnordost/streetcomplete/ApplicationModule.kt @@ -3,6 +3,7 @@ package de.westnordost.streetcomplete import android.content.Intent import android.content.res.AssetManager import android.content.res.Resources +import de.westnordost.streetcomplete.data.karta_view.KartaViewApiClient import de.westnordost.streetcomplete.data.preferences.EnvironmentManager import de.westnordost.streetcomplete.data.preferences.Preferences import de.westnordost.streetcomplete.data.workspace.domain.model.LoginResponse @@ -147,6 +148,8 @@ val appModule = module { } } + single { KartaViewApiClient(get(), get(named("kartaViewClient")), get()) } + single { Res } single { SystemFileSystem } single { DefaultResourceProvider(androidContext(), get()) } @@ -170,7 +173,7 @@ suspend fun refreshJwtToken( } val response = - tempClient.post(environmentManager.currentEnvironment.tdeiApiBaseUrl + "/refresh-token") { + tempClient.post(environmentManager.currentEnvironment.tdeiUserManagementBaseurl + "/refresh-token") { setBody(preferences.workspaceRefreshToken) headers { contentType(ContentType.Application.Json) diff --git a/app/src/androidMain/kotlin/de/westnordost/streetcomplete/data/workspace/data/remote/WorkspaceApiService.kt b/app/src/androidMain/kotlin/de/westnordost/streetcomplete/data/workspace/data/remote/WorkspaceApiService.kt index dd2060a8ffc..a7e184db653 100644 --- a/app/src/androidMain/kotlin/de/westnordost/streetcomplete/data/workspace/data/remote/WorkspaceApiService.kt +++ b/app/src/androidMain/kotlin/de/westnordost/streetcomplete/data/workspace/data/remote/WorkspaceApiService.kt @@ -64,7 +64,7 @@ class WorkspaceApiService( } suspend fun getTDEIUserDetails(emailId: String): UserInfoResponse { - val url = environmentManager.currentEnvironment.tdeiUserManagementBaseurl + val url = environmentManager.currentEnvironment.tdeiApiBaseUrl try { val response = performHttpCallWithFirebaseTracing( 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 ba10da5be55..3a0ab647c17 100644 --- a/app/src/androidMain/kotlin/de/westnordost/streetcomplete/quests/AbstractOsmQuestForm.kt +++ b/app/src/androidMain/kotlin/de/westnordost/streetcomplete/quests/AbstractOsmQuestForm.kt @@ -27,8 +27,7 @@ import com.google.android.material.snackbar.Snackbar 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.PhotoLookupResponse +import de.westnordost.streetcomplete.data.karta_view.KartaViewApiClient import de.westnordost.streetcomplete.data.location.SurveyChecker import de.westnordost.streetcomplete.data.osm.edits.AddElementEditsController import de.westnordost.streetcomplete.data.osm.edits.ElementEditAction @@ -43,6 +42,7 @@ import de.westnordost.streetcomplete.data.osm.geometry.ElementGeometry import de.westnordost.streetcomplete.data.osm.geometry.ElementPolylinesGeometry import de.westnordost.streetcomplete.data.osm.mapdata.Element import de.westnordost.streetcomplete.data.osm.mapdata.ElementType +import de.westnordost.streetcomplete.data.osm.mapdata.LatLon import de.westnordost.streetcomplete.data.osm.mapdata.Node import de.westnordost.streetcomplete.data.osm.mapdata.Way import de.westnordost.streetcomplete.data.osm.osmquests.OsmElementQuestType @@ -53,7 +53,6 @@ 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 @@ -62,17 +61,6 @@ import de.westnordost.streetcomplete.util.ktx.isSplittable import de.westnordost.streetcomplete.util.ktx.viewLifecycleScope import de.westnordost.streetcomplete.view.add import de.westnordost.streetcomplete.view.confirmIsSurvey -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 -import io.ktor.http.HttpHeaders -import io.ktor.http.HttpStatusCode import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -95,8 +83,7 @@ abstract class AbstractOsmQuestForm : AbstractQuestForm(), IsShowingQuestDeta private val surveyChecker: SurveyChecker by inject() protected val featureDictionary: FeatureDictionary get() = featureDictionaryLazy.value - private val httpClient: HttpClient by inject(named("kartaViewClient")) - private val prefs: Preferences by inject() + private val kartaViewApiClient: KartaViewApiClient by inject() private lateinit var cameraLauncher: ActivityResultLauncher // only used for testing / only used for ShowQuestFormsScreen! Found no better way to do this @@ -169,14 +156,13 @@ abstract class AbstractOsmQuestForm : AbstractQuestForm(), IsShowingQuestDeta if (getElement != null) { element = getElement } - val displayedLocation = args.getParcelable(ARG_DISPLAYED_LOCATION) cameraLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> if (result.resultCode == Activity.RESULT_OK) { showProgressbar() // Handle the image capture result here val bitmap = result.data?.extras?.getParcelable("data") - startKartViewFlow(bitmap, displayedLocation) + startKartViewFlow(bitmap) } else { // Handle the error state here @@ -446,109 +432,37 @@ abstract class AbstractOsmQuestForm : AbstractQuestForm(), IsShowingQuestDeta } } - private fun startKartViewFlow(bitmap: Bitmap?, displayedLocation: Location?) { + private fun startKartViewFlow(bitmap: Bitmap?) { viewLifecycleScope.launch { - 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) - onImageUrlReceived(lthUrl) - } else { - hideProgressbar() - Log.e("KartViewFlow", "Failed to retrieve photo URL") - showSnackBar("Failed to retrieve photo URL. Please try again later.", view, requireActivity() as ComponentActivity) - } - } else { + val displayedLocation = listener?.displayedMapLocation + if (bitmap == null || displayedLocation == null) { hideProgressbar() - Log.e("KartViewFlow", "Image upload failed") - showSnackBar("Image upload failed. Please try again later.", view, requireActivity() as ComponentActivity) + return@launch } - } - } - - 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 = prefs.kartaViewAccessToken - val response = - httpClient.post("https://api.openstreetcam.org/1.0/sequence/finished-uploading/") { - setBody(MultiPartFormDataContent(formData { - append("access_token", token) - append("sequenceId", sequenceId) - })) + val bearing = if (displayedLocation.hasBearing() && displayedLocation.bearing != 0f) { + displayedLocation.bearing + } else { + compassBearing.toFloat() + } + val byteArrayOutputStream = ByteArrayOutputStream() + bitmap.compress(Bitmap.CompressFormat.JPEG, 100, byteArrayOutputStream) + try { + val urls = kartaViewApiClient.uploadImages( + listOf(byteArrayOutputStream.toByteArray()), + LatLon(displayedLocation.latitude, displayedLocation.longitude), + bearing + ) + showSnackBar("Image Uploaded Successfully", view, requireActivity() as ComponentActivity) + onImageUrlReceived(urls.first()) + } catch (e: Exception) { + Log.e("KartViewFlow", "KartaView upload failed", e) + showSnackBar( + e.message ?: "Image upload failed. Please try again later.", + view, requireActivity() as ComponentActivity + ) + } finally { + hideProgressbar() } - if (response.status == HttpStatusCode.OK) { - val sequence = response.body() - Log.d("KartViewSequence", sequence.status.httpMessage) - } else { - showSnackBar( - "Failed to close KartaView Sequence. Please try again later " + response.status, - view, requireActivity() as ComponentActivity - ) - } - hideProgressbar() - } - - private suspend fun uploadImageInSequence( - sequenceId: String, - bitmap: Bitmap?, - location: Location?, - ): Boolean { - val displayedLocation = listener?.displayedMapLocation ?: return false - bitmap ?: return false - - 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 } } @@ -558,28 +472,6 @@ abstract class AbstractOsmQuestForm : AbstractQuestForm(), IsShowingQuestDeta } } - private suspend fun createSequence(): String? { - val token = prefs.kartaViewAccessToken - val response = httpClient.post("https://api.openstreetcam.org/1.0/sequence/") { - setBody(MultiPartFormDataContent(formData { - append("access_token", token) - })) - } - if (response.status == HttpStatusCode.OK) { - val sequence = response.body() - sequence.osv.sequence?.id?.let { Log.d("KartViewSequence", it) } - Log.d("KartViewStatus", sequence.status.httpMessage) - return sequence.osv.sequence?.id - } else { - hideProgressbar() - showSnackBar( - "Failed to create KartaView sequence. Image upload failed. Please try again later " + response.status, - view, requireActivity() as ComponentActivity - ) - return null - } - } - companion object { private const val ARG_ELEMENT = "element" private const val ARG_DISPLAYED_LOCATION = "displayedLocation" diff --git a/app/src/androidMain/kotlin/de/westnordost/streetcomplete/screens/main/MainActivity.kt b/app/src/androidMain/kotlin/de/westnordost/streetcomplete/screens/main/MainActivity.kt index 1c6a872014d..741581f8488 100644 --- a/app/src/androidMain/kotlin/de/westnordost/streetcomplete/screens/main/MainActivity.kt +++ b/app/src/androidMain/kotlin/de/westnordost/streetcomplete/screens/main/MainActivity.kt @@ -45,6 +45,8 @@ import androidx.core.net.toUri import androidx.core.os.bundleOf import androidx.core.view.AccessibilityDelegateCompat import androidx.core.view.ViewCompat +import androidx.core.view.isGone +import androidx.core.view.isVisible import androidx.core.view.accessibility.AccessibilityNodeInfoCompat import androidx.fragment.app.Fragment import androidx.fragment.app.FragmentManager @@ -572,6 +574,7 @@ class MainActivity : binding.contextMenuView.translationX = point.x binding.contextMenuView.translationY = point.y + showTemporaryMarker(point) showMapContextMenu(position) } @@ -1103,9 +1106,24 @@ class MainActivity : } true } + // fires on both item selection and cancel, after the click listener ran + popupMenu.setOnDismissListener { hideTemporaryMarker() } popupMenu.show() } + /** Shows the pin overlay with its tip at the long-pressed screen point. The map can't be + * panned while the context menu popup is open, so a static view overlay stays accurate. */ + private fun showTemporaryMarker(point: PointF) { + val markerView = binding.longPressMarkerView + markerView.translationX = point.x - markerView.layoutParams.width / 2f + markerView.translationY = point.y - markerView.layoutParams.height + markerView.isVisible = true + } + + private fun hideTemporaryMarker() { + binding.longPressMarkerView.isGone = true + } + private fun showOverlaysMenu(position1: LatLon) { val overlay = overlayRegistry[Random.nextInt(overlayRegistry.size)] (overlay as ThingsOverlay).position = position1 @@ -1457,7 +1475,7 @@ class MainActivity : val osmArgs = AbstractOsmQuestForm.createArguments(element, mapFragment.displayedLocation) f.requireArguments().putAll(osmArgs) - showHighlightedElements(quest, element) + //showHighlightedElements(quest, element) } showInBottomSheet(f) diff --git a/app/src/androidMain/kotlin/de/westnordost/streetcomplete/screens/workspaces/WorkspaceLoginScreen.kt b/app/src/androidMain/kotlin/de/westnordost/streetcomplete/screens/workspaces/WorkspaceLoginScreen.kt index c2bca6e7479..73463aabf69 100644 --- a/app/src/androidMain/kotlin/de/westnordost/streetcomplete/screens/workspaces/WorkspaceLoginScreen.kt +++ b/app/src/androidMain/kotlin/de/westnordost/streetcomplete/screens/workspaces/WorkspaceLoginScreen.kt @@ -365,7 +365,7 @@ fun LoginCard( ) { Column( horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(18.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), modifier = Modifier .weight(1f) .fillMaxWidth() @@ -482,7 +482,7 @@ fun LoginCard( Column( horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(4.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth() ) { Button( @@ -527,9 +527,10 @@ fun LoginCard( } } } - Spacer(modifier = Modifier.height(24.dp)) - + Spacer(modifier = Modifier.weight(1f)) UserInfoComponent() + Spacer(modifier = Modifier.weight(1f)) + } DebuggableBuild( @@ -768,4 +769,3 @@ suspend fun authenticateWithBiometrics( biometricHelper.authenticate() } - diff --git a/app/src/androidMain/res/drawable/ic_pin_with_shadow.xml b/app/src/androidMain/res/drawable/ic_pin_with_shadow.xml new file mode 100644 index 00000000000..4a554ab11b5 --- /dev/null +++ b/app/src/androidMain/res/drawable/ic_pin_with_shadow.xml @@ -0,0 +1,23 @@ + + + + + + + + + + + + diff --git a/app/src/androidMain/res/layout/activity_main.xml b/app/src/androidMain/res/layout/activity_main.xml index eb75044cc76..abb617c07af 100644 --- a/app/src/androidMain/res/layout/activity_main.xml +++ b/app/src/androidMain/res/layout/activity_main.xml @@ -24,6 +24,7 @@ @@ -33,6 +34,15 @@ android:layout_height="0dp" tools:ignore="MissingConstraints" /> + + + + diff --git a/app/src/androidMain/res/values/conf.xml b/app/src/androidMain/res/values/conf.xml index c5e57139d76..2697885525a 100644 --- a/app/src/androidMain/res/values/conf.xml +++ b/app/src/androidMain/res/values/conf.xml @@ -1,4 +1,4 @@ - de.westnordost.streetcomplete.fileprovider + net.opentoall.aviv.scoutroute.fileprovider diff --git a/app/src/androidMain/res/xml/file_paths.xml b/app/src/androidMain/res/xml/file_paths.xml index 195dcc2cab0..bc55a97853d 100644 --- a/app/src/androidMain/res/xml/file_paths.xml +++ b/app/src/androidMain/res/xml/file_paths.xml @@ -1,4 +1,6 @@ - + + diff --git a/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/karta_view/KartaViewApiClient.kt b/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/karta_view/KartaViewApiClient.kt new file mode 100644 index 00000000000..a4a6b708416 --- /dev/null +++ b/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/karta_view/KartaViewApiClient.kt @@ -0,0 +1,138 @@ +package de.westnordost.streetcomplete.data.karta_view + +import de.westnordost.streetcomplete.data.karta_view.domain.model.CreateSequenceResponse +import de.westnordost.streetcomplete.data.karta_view.domain.model.PhotoLookupResponse +import de.westnordost.streetcomplete.data.osm.mapdata.LatLon +import de.westnordost.streetcomplete.data.preferences.Preferences +import de.westnordost.streetcomplete.util.logs.Log +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 +import io.ktor.http.HttpHeaders +import io.ktor.http.HttpStatusCode +import kotlinx.io.buffered +import kotlinx.io.files.FileSystem +import kotlinx.io.files.Path +import kotlinx.io.readByteArray + +/** Uploads photos to KartaView (api.openstreetcam.org). */ +class KartaViewApiClient( + private val fileSystem: FileSystem, + private val httpClient: HttpClient, + private val prefs: Preferences, +) { + /** Uploads the image files at [imagePaths] into one new KartaView sequence at [position], + * in list order. Paths that don't exist are skipped. Returns the lth photo URL for each + * uploaded image, in the same order. + * + * @throws KartaViewException naming the step that failed */ + suspend fun upload(imagePaths: List, position: LatLon, bearing: Float = 0f): List { + val images = imagePaths.mapNotNull { path -> + val file = Path(path) + if (fileSystem.exists(file)) fileSystem.source(file).buffered().readByteArray() else null + } + return uploadImages(images, position, bearing) + } + + /** Uploads [images] (JPEG-encoded) into one new KartaView sequence at [position], in list + * order (sequenceIndex 1..n), closes the sequence and returns the lth photo URL for each + * image, in the same order. + * + * @throws KartaViewException naming the step that failed */ + suspend fun uploadImages(images: List, position: LatLon, bearing: Float = 0f): List { + if (images.isEmpty()) return emptyList() + val sequenceId = createSequence() + images.forEachIndexed { index, image -> + uploadPhoto(sequenceId, index + 1, image, position, bearing) + } + closeSequence(sequenceId) + return List(images.size) { index -> getPhotoLthUrl(sequenceId, index + 1) } + } + + private suspend fun createSequence(): String { + val response = httpClient.post(BASE_URL + "1.0/sequence/") { + setBody(MultiPartFormDataContent(formData { + append("access_token", prefs.kartaViewAccessToken) + })) + } + if (response.status == HttpStatusCode.OK) { + val sequence = response.body() + Log.d(TAG, "Sequence created: ${sequence.status.httpMessage}") + sequence.osv.sequence?.id?.let { return it } + } + throw KartaViewException( + "Failed to create KartaView sequence. Image upload failed. Please try again later " + response.status + ) + } + + private suspend fun uploadPhoto( + sequenceId: String, + sequenceIndex: Int, + image: ByteArray, + position: LatLon, + bearing: Float, + ) { + val response = httpClient.post(BASE_URL + "1.0/photo/") { + setBody(MultiPartFormDataContent(formData { + append("access_token", prefs.kartaViewAccessToken) + append("sequenceId", sequenceId) + append("sequenceIndex", sequenceIndex) + append("coordinate", "${position.latitude},${position.longitude}") + append("headers", bearing.toInt().toString()) + append("photo", image, Headers.build { + append(HttpHeaders.ContentType, "image/jpeg") + append(HttpHeaders.ContentDisposition, "filename=\"wework-kartaview.jpg\"") + }) + })) + } + if (response.status != HttpStatusCode.OK) { + throw KartaViewException( + "Failed to upload image to KartaView. Please try again later " + response.status + ) + } + Log.d(TAG, "Image $sequenceIndex uploaded to sequence $sequenceId") + } + + private suspend fun closeSequence(sequenceId: String) { + val response = httpClient.post(BASE_URL + "1.0/sequence/finished-uploading/") { + setBody(MultiPartFormDataContent(formData { + append("access_token", prefs.kartaViewAccessToken) + append("sequenceId", sequenceId) + })) + } + if (response.status != HttpStatusCode.OK) { + throw KartaViewException( + "Failed to close KartaView Sequence. Please try again later " + response.status + ) + } + Log.d(TAG, "Sequence closed: ${response.body().status.httpMessage}") + } + + private suspend fun getPhotoLthUrl(sequenceId: String, sequenceIndex: Int): String { + val response = httpClient.get(BASE_URL + "2.0/photo/") { + parameter("access_token", prefs.kartaViewAccessToken) + parameter("sequenceId", sequenceId) + parameter("sequenceIndex", sequenceIndex) + } + if (response.status == HttpStatusCode.OK) { + val url = response.body().result?.data?.firstOrNull()?.imageLthUrl + if (url != null) return url + } + throw KartaViewException( + "Failed to retrieve photo URL. Please try again later " + response.status + ) + } + + companion object { + private const val TAG = "KartaViewApiClient" + private const val BASE_URL = "https://api.openstreetcam.org/" + } +} + +class KartaViewException(message: String) : RuntimeException(message) diff --git a/app/src/androidMain/kotlin/de/westnordost/streetcomplete/data/karta_view/domain/model/CreateSequenceResponse.kt b/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/karta_view/domain/model/CreateSequenceResponse.kt similarity index 100% rename from app/src/androidMain/kotlin/de/westnordost/streetcomplete/data/karta_view/domain/model/CreateSequenceResponse.kt rename to app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/karta_view/domain/model/CreateSequenceResponse.kt diff --git a/app/src/androidMain/kotlin/de/westnordost/streetcomplete/data/karta_view/domain/model/ImageUploadResponse.kt b/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/karta_view/domain/model/ImageUploadResponse.kt similarity index 100% rename from app/src/androidMain/kotlin/de/westnordost/streetcomplete/data/karta_view/domain/model/ImageUploadResponse.kt rename to app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/karta_view/domain/model/ImageUploadResponse.kt diff --git a/app/src/androidMain/kotlin/de/westnordost/streetcomplete/data/karta_view/domain/model/Osv.kt b/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/karta_view/domain/model/Osv.kt similarity index 100% rename from app/src/androidMain/kotlin/de/westnordost/streetcomplete/data/karta_view/domain/model/Osv.kt rename to app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/karta_view/domain/model/Osv.kt diff --git a/app/src/androidMain/kotlin/de/westnordost/streetcomplete/data/karta_view/domain/model/OsvX.kt b/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/karta_view/domain/model/OsvX.kt similarity index 100% rename from app/src/androidMain/kotlin/de/westnordost/streetcomplete/data/karta_view/domain/model/OsvX.kt rename to app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/karta_view/domain/model/OsvX.kt diff --git a/app/src/androidMain/kotlin/de/westnordost/streetcomplete/data/karta_view/domain/model/Photo.kt b/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/karta_view/domain/model/Photo.kt similarity index 100% rename from app/src/androidMain/kotlin/de/westnordost/streetcomplete/data/karta_view/domain/model/Photo.kt rename to app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/karta_view/domain/model/Photo.kt diff --git a/app/src/androidMain/kotlin/de/westnordost/streetcomplete/data/karta_view/domain/model/PhotoLookupResponse.kt b/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/karta_view/domain/model/PhotoLookupResponse.kt similarity index 100% rename from app/src/androidMain/kotlin/de/westnordost/streetcomplete/data/karta_view/domain/model/PhotoLookupResponse.kt rename to app/src/commonMain/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/Sequence.kt b/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/karta_view/domain/model/Sequence.kt similarity index 100% rename from app/src/androidMain/kotlin/de/westnordost/streetcomplete/data/karta_view/domain/model/Sequence.kt rename to app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/karta_view/domain/model/Sequence.kt diff --git a/app/src/androidMain/kotlin/de/westnordost/streetcomplete/data/karta_view/domain/model/Status.kt b/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/karta_view/domain/model/Status.kt similarity index 100% rename from app/src/androidMain/kotlin/de/westnordost/streetcomplete/data/karta_view/domain/model/Status.kt rename to app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/karta_view/domain/model/Status.kt diff --git a/app/src/androidMain/kotlin/de/westnordost/streetcomplete/data/karta_view/domain/model/StatusX.kt b/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/karta_view/domain/model/StatusX.kt similarity index 100% rename from app/src/androidMain/kotlin/de/westnordost/streetcomplete/data/karta_view/domain/model/StatusX.kt rename to app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/karta_view/domain/model/StatusX.kt diff --git a/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/osmnotes/NotesApiClient.kt b/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/osmnotes/NotesApiClient.kt index 5afbb6d0650..df2d33bc733 100644 --- a/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/osmnotes/NotesApiClient.kt +++ b/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/osmnotes/NotesApiClient.kt @@ -47,8 +47,8 @@ class NotesApiClient( */ suspend fun create(pos: LatLon, text: String): Note = wrapApiClientExceptions { val response = httpClient.post(workspaceConfigProvider.osmBaseUrl + "notes") { - userAccessTokenSource.accessToken?.let { bearerAuth(it) } header("X-Workspace", workspaceConfigProvider.workspaceId.toString()) + workspaceConfigProvider.workspaceToken?.let { bearerAuth(it) } parameter("lat", pos.latitude.format(7)) parameter("lon", pos.longitude.format(7)) parameter("text", text) @@ -72,7 +72,7 @@ class NotesApiClient( suspend fun comment(id: Long, text: String): Note = wrapApiClientExceptions { try { val response = httpClient.post(workspaceConfigProvider.osmBaseUrl + "notes/$id/comment") { - userAccessTokenSource.accessToken?.let { bearerAuth(it) } + workspaceConfigProvider.workspaceToken?.let { bearerAuth(it) } parameter("text", text) expectSuccess = true } @@ -100,7 +100,7 @@ class NotesApiClient( try { val response = httpClient.get(workspaceConfigProvider.osmBaseUrl + "notes/$id") { expectSuccess = true header("X-Workspace", workspaceConfigProvider.workspaceId.toString()) - userAccessTokenSource.accessToken?.let { bearerAuth(it) } + workspaceConfigProvider.workspaceToken?.let { bearerAuth(it) } } val source = response.bodyAsChannel().asSource().buffered() return notesApiParser.parseNotes(source, workspaceConfigProvider.workspaceId).singleOrNull() @@ -134,8 +134,7 @@ class NotesApiClient( try { val response = httpClient.get(workspaceConfigProvider.osmBaseUrl + "notes") { header("X-Workspace", workspaceConfigProvider.workspaceId.toString()) - - userAccessTokenSource.accessToken?.let { bearerAuth(it) } + workspaceConfigProvider.workspaceToken?.let { bearerAuth(it) } parameter("bbox", bounds.toOsmApiString()) parameter("limit", limit) parameter("closed", 0) diff --git a/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/osmnotes/NotesModule.kt b/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/osmnotes/NotesModule.kt index 52ef0e7841c..dedf4d32675 100644 --- a/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/osmnotes/NotesModule.kt +++ b/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/osmnotes/NotesModule.kt @@ -1,6 +1,5 @@ package de.westnordost.streetcomplete.data.osmnotes -import de.westnordost.streetcomplete.ApplicationConstants import org.koin.core.qualifier.named import org.koin.dsl.module @@ -9,7 +8,6 @@ val notesModule = module { factory { AvatarsInNotesUpdater(get()) } factory { NoteDao(get(), get()) } factory { NotesDownloader(get(), get()) } - factory { PhotoServiceApiClient(get(), get(), ApplicationConstants.SC_PHOTO_SERVICE_URL) } single { NoteController(get()).apply { diff --git a/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/osmnotes/edits/NoteEditsUploader.kt b/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/osmnotes/edits/NoteEditsUploader.kt index f7166502eaf..b1dee408b47 100644 --- a/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/osmnotes/edits/NoteEditsUploader.kt +++ b/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/osmnotes/edits/NoteEditsUploader.kt @@ -2,9 +2,10 @@ package de.westnordost.streetcomplete.data.osmnotes.edits import de.westnordost.streetcomplete.ApplicationConstants import de.westnordost.streetcomplete.data.ConflictException +import de.westnordost.streetcomplete.data.karta_view.KartaViewApiClient +import de.westnordost.streetcomplete.data.osm.mapdata.LatLon import de.westnordost.streetcomplete.data.osmnotes.NoteController import de.westnordost.streetcomplete.data.osmnotes.NotesApiClient -import de.westnordost.streetcomplete.data.osmnotes.PhotoServiceApiClient 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 @@ -30,7 +31,7 @@ class NoteEditsUploader( private val userDataSource: UserDataSource, private val notesApi: NotesApiClient, private val tracksApi: TracksApiClient, - private val imageUploader: PhotoServiceApiClient, + private val imageUploader: KartaViewApiClient, private val fileSystem: FileSystem, ) { var uploadedChangeListener: OnUploadedChangeListener? = null @@ -51,13 +52,11 @@ class NoteEditsUploader( } } private suspend fun uploadMissedImageActivations() { + // KartaView photos need no activation step (their URLs are embedded in the note text at + // upload) - just clear any flags still set, e.g. from before the switch to KartaView while (true) { val edit = noteEditsController.getOldestNeedingImagesActivation() ?: break - // see uploadEdits - withContext(scope.coroutineContext) { - imageUploader.activate(edit.noteId) - noteEditsController.markImagesActivated(edit.id) - } + noteEditsController.markImagesActivated(edit.id) } } @@ -73,7 +72,7 @@ class NoteEditsUploader( private suspend fun uploadEdit(edit: NoteEdit) { // try to upload the image and track if we have them - val imageText = uploadAndGetAttachedPhotosText(edit.imagePaths) + val imageText = uploadAndGetAttachedPhotosText(edit.imagePaths, edit.position) val trackText = uploadAndGetAttachedTrackText(edit.track, edit.text) val text = edit.text.orEmpty() + imageText + trackText @@ -94,7 +93,6 @@ class NoteEditsUploader( noteController.put(note) if (edit.imagePaths.isNotEmpty()) { - imageUploader.activate(note.id) noteEditsController.markImagesActivated(note.id) } @@ -124,9 +122,9 @@ class NoteEditsUploader( } } - private suspend fun uploadAndGetAttachedPhotosText(imagePaths: List): String { + private suspend fun uploadAndGetAttachedPhotosText(imagePaths: List, position: LatLon): String { if (imagePaths.isNotEmpty()) { - val urls = imageUploader.upload(imagePaths) + val urls = imageUploader.upload(imagePaths, position) if (urls.isNotEmpty()) { return "\n\nAttached photo(s):\n" + urls.joinToString("\n") } 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 8f48ecb427b..d5bba25c365 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 @@ -8,6 +8,7 @@ import com.russhwolf.settings.int import com.russhwolf.settings.long import com.russhwolf.settings.nullableString import com.russhwolf.settings.set +import de.westnordost.streetcomplete.BuildConfig import de.westnordost.streetcomplete.data.osm.mapdata.LatLon import de.westnordost.streetcomplete.util.ktx.putStringOrNull import kotlinx.coroutines.flow.MutableStateFlow @@ -214,7 +215,7 @@ class Preferences(private val prefs: ObservableSettings) { var kartaViewAccessToken: String set(value) { prefs.putString(KARTAVIEW_ACCESS_TOKEN, value) } - get() = prefs.getString(KARTAVIEW_ACCESS_TOKEN, DEFAULT_KARTAVIEW_ACCESS_TOKEN) + get() = prefs.getString(KARTAVIEW_ACCESS_TOKEN, BuildConfig.KARTAVIEW_ACCESS_TOKEN) var isFollowModeEnabled: Boolean set(value) { @@ -445,7 +446,5 @@ class Preferences(private val prefs: ObservableSettings) { 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 = - "5b7bfc9104ad198bdba4108ed3048f380f118cfb528d750a2bf31041b7266a99" } } diff --git a/app/src/main/res/drawable/ic_pin.png b/app/src/main/res/drawable/ic_pin.png new file mode 100644 index 00000000000..8c9cadcbc75 Binary files /dev/null and b/app/src/main/res/drawable/ic_pin.png differ diff --git a/build.gradle.kts b/build.gradle.kts index 64632d767e6..a648023c9c3 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -3,8 +3,8 @@ plugins { // in each subproject's classloader id("org.jetbrains.kotlin.multiplatform") version "2.2.21" apply false id("org.jetbrains.kotlin.plugin.compose") version "2.2.21" apply false - id("com.android.application") version "8.13.0" apply false - id("com.android.library") version "8.13.0" apply false + id("com.android.application") version "9.2.1" apply false + id("com.android.library") version "9.2.1" apply false id("org.jetbrains.compose") version "1.9.2" apply false id("org.jetbrains.kotlinx.atomicfu") version "0.29.0" apply false id("com.google.gms.google-services") version "4.4.4" apply false diff --git a/gradle.properties b/gradle.properties index 75e682150aa..af4f03a775a 100644 --- a/gradle.properties +++ b/gradle.properties @@ -23,3 +23,13 @@ kotlin.daemon.jvmargs=-Xmx4096m org.gradle.caching=true org.gradle.configuration-cache=false org.gradle.jvmargs=-Xmx1536M -Dkotlin.daemon.jvm.options\="-Xmx1536M" -Dfile.encoding\=UTF-8 +android.defaults.buildfeatures.resvalues=true +android.sdk.defaultTargetSdkToCompileSdkIfUnset=false +android.enableAppCompileTimeRClass=false +android.usesSdkInManifest.disallowed=false +android.uniquePackageNames=false +android.dependency.useConstraints=true +android.r8.strictFullModeForKeepRules=false +android.r8.optimizedResourceShrinking=false +android.builtInKotlin=false +android.newDsl=false diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index ca025c83a7c..a351597e62a 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.6.1-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME diff --git a/secrets.properties.example b/secrets.properties.example new file mode 100644 index 00000000000..91b949ea791 --- /dev/null +++ b/secrets.properties.example @@ -0,0 +1,8 @@ +# Copy this file to secrets.properties (git-ignored) and fill in the values. +# Alternatively, each value can be supplied via an environment variable of the +# same name as noted below (the env var takes precedence). + +# KartaView (api.openstreetcam.org) access token used for photo uploads. +# Obtain one from the KartaView account that owns the app's photo sequences. +# Env var alternative: KARTAVIEW_ACCESS_TOKEN +kartaViewAccessToken= diff --git a/settings.gradle.kts b/settings.gradle.kts index 37dcaba2f05..5913e4e874f 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -13,6 +13,9 @@ pluginManagement { gradlePluginPortal() } } +plugins { + id("org.gradle.toolchains.foojay-resolver-convention") version "1.0.0" +} dependencyResolutionManagement { repositories {