From feab1e4c7d3df693684426d49af30814d672c6a1 Mon Sep 17 00:00:00 2001 From: Rajesh Kantipudi <44539669+iamrajeshk@users.noreply.github.com> Date: Fri, 10 Jul 2026 12:21:06 +0530 Subject: [PATCH 01/19] Bump app version to 1.2.7 and update version code to 16; refactor API clients to use workspace token for authentication --- app/build.gradle.kts | 4 ++-- .../streetcomplete/data/osm/mapdata/MapDataApiClient.kt | 2 ++ .../streetcomplete/data/osmnotes/NotesApiClient.kt | 8 ++++---- 3 files changed, 8 insertions(+), 6 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 74b670a2c6a..36ccd7ec231 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -9,8 +9,8 @@ import java.util.Properties /** App version name, code and flavor */ -val appVersionName = "1.2.4" -val appVersionCode = 14 +val appVersionName = "1.2.7" +val appVersionCode = 16 /** Localizations the app should be available in */ val bcp47ExportLanguages = setOf( diff --git a/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/osm/mapdata/MapDataApiClient.kt b/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/osm/mapdata/MapDataApiClient.kt index 47fa030d0c0..bf6782b9452 100644 --- a/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/osm/mapdata/MapDataApiClient.kt +++ b/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/osm/mapdata/MapDataApiClient.kt @@ -122,6 +122,7 @@ class MapDataApiClient( val response = httpClient.get(workspaceConfigProvider.osmBaseUrl + "map") { parameter("bbox", bounds.toOsmApiString()) header("X-Workspace", workspaceConfigProvider.workspaceId.toString()) + workspaceConfigProvider.workspaceToken?.let { bearerAuth(it) } expectSuccess = true } val source = response.bodyAsChannel().asSource().buffered() @@ -212,6 +213,7 @@ class MapDataApiClient( try { val response = httpClient.get(workspaceConfigProvider.osmBaseUrl + query) { header("X-Workspace", workspaceConfigProvider.workspaceId.toString()) + workspaceConfigProvider.workspaceToken?.let { bearerAuth(it) } expectSuccess = true } val source = response.bodyAsChannel().asSource().buffered() 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 4f5ca2fff59..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,6 +100,7 @@ class NotesApiClient( try { val response = httpClient.get(workspaceConfigProvider.osmBaseUrl + "notes/$id") { expectSuccess = true header("X-Workspace", workspaceConfigProvider.workspaceId.toString()) + workspaceConfigProvider.workspaceToken?.let { bearerAuth(it) } } val source = response.bodyAsChannel().asSource().buffered() return notesApiParser.parseNotes(source, workspaceConfigProvider.workspaceId).singleOrNull() @@ -133,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) From 81b9b837f71854d0ae94795116b2ee230f4e5009 Mon Sep 17 00:00:00 2001 From: Rajesh Kantipudi <44539669+iamrajeshk@users.noreply.github.com> Date: Wed, 1 Jul 2026 15:16:24 +0530 Subject: [PATCH 02/19] Update environment endpoints and bump app version add "Forgot password?" link in login screen --- .../streetcomplete/ApplicationModule.kt | 2 +- .../data/remote/WorkspaceApiService.kt | 6 +++--- .../screens/workspaces/WorkspaceLoginScreen.kt | 18 ++++++++++++++++-- .../data/preferences/WorkspaceConstants.kt | 14 +++++++++----- 4 files changed, 29 insertions(+), 11 deletions(-) diff --git a/app/src/androidMain/kotlin/de/westnordost/streetcomplete/ApplicationModule.kt b/app/src/androidMain/kotlin/de/westnordost/streetcomplete/ApplicationModule.kt index 3168ee2666b..80521876bcd 100644 --- a/app/src/androidMain/kotlin/de/westnordost/streetcomplete/ApplicationModule.kt +++ b/app/src/androidMain/kotlin/de/westnordost/streetcomplete/ApplicationModule.kt @@ -170,7 +170,7 @@ suspend fun refreshJwtToken( } val response = - tempClient.post(environmentManager.currentEnvironment.loginUrl + "/refresh-token") { + tempClient.post(environmentManager.currentEnvironment.tdeiUserManagementEndpoint + "/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 aad680d15ba..20ec4a4f347 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.tdeiUrl + val url = environmentManager.currentEnvironment.tdeiApiUrl try { val response = performHttpCallWithFirebaseTracing( @@ -107,7 +107,7 @@ class WorkspaceApiService( } suspend fun loginToWorkspace(username: String, password: String): LoginResponse { - val url = environmentManager.currentEnvironment.loginUrl + "/authenticate" + val url = environmentManager.currentEnvironment.tdeiUserManagementEndpoint + "/authenticate" try { val response = performHttpCallWithFirebaseTracing( client = httpClient, @@ -145,7 +145,7 @@ class WorkspaceApiService( } suspend fun refreshToken(refreshToken: String): LoginResponse { - val url = environmentManager.currentEnvironment.loginUrl + "/refresh-token" + val url = environmentManager.currentEnvironment.tdeiUserManagementEndpoint + "/refresh-token" try { val response = performHttpCallWithFirebaseTracing( 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 0ca1640270b..b1b709a3097 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 @@ -44,6 +44,7 @@ import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Surface import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.MutableState @@ -58,7 +59,6 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalFocusManager @@ -456,6 +456,20 @@ fun LoginCard( modifier = Modifier .fillMaxWidth() ) + + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) { + TextButton(onClick = { + // open URL in browser + val url = + selectedEnvironment.value.tdeiWebUrl + "/ForgotPassword" + val intent = Intent(Intent.ACTION_VIEW) + intent.data = url.toUri() + context.startActivity(intent) + }) { + Text("Forgot password?", color = MaterialTheme.colorScheme.primary) + } + } + if (isDebugModeEnabled) { EnvironmentDropdownMenu(viewModel, selectedEnvironment, modifier = Modifier) } @@ -497,7 +511,7 @@ fun LoginCard( fun UserInfoComponent() { Column( modifier = Modifier.fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), horizontalAlignment = Alignment.CenterHorizontally ) { val context = LocalContext.current diff --git a/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/preferences/WorkspaceConstants.kt b/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/preferences/WorkspaceConstants.kt index ec9f7663fc7..9e726cd3717 100644 --- a/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/preferences/WorkspaceConstants.kt +++ b/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/preferences/WorkspaceConstants.kt @@ -2,9 +2,10 @@ package de.westnordost.streetcomplete.data.preferences enum class Environment( val baseUrl: String, - val loginUrl: String, - val tdeiUrl: String, + val tdeiUserManagementEndpoint: String, + val tdeiApiUrl: String, val osmUrl: String, + val tdeiWebUrl: String, val appUpdateVersionCheckUrl: String = APP_UPDATE_VERSION_CHECKER_URL, val firebaseUpdateUrl: String = FIREBASE_UPDATE_URL ) { @@ -12,19 +13,22 @@ enum class Environment( "https://api.workspaces-stage.sidewalks.washington.edu/api/v1/workspaces", "https://tdei-gateway-stage.azurewebsites.net/api/v1", "https://tdei-usermanagement-stage.azurewebsites.net/api/v1/user-profile", - "https://osm-workspaces-proxy.azurewebsites.net/stage/api/0.6/" + "https://osm-workspaces-proxy.azurewebsites.net/stage/api/0.6/", + "https://portal-stage.tdei.us" ), DEV( "https://api.workspaces-dev.sidewalks.washington.edu/api/v1/workspaces", "https://tdei-api-dev.azurewebsites.net/api/v1", "https://tdei-usermanagement-be-dev.azurewebsites.net/api/v1/user-profile", - "https://osm-workspaces-proxy.azurewebsites.net/dev/api/0.6/" + "https://osm-workspaces-proxy.azurewebsites.net/dev/api/0.6/", + "https://portal-dev.tdei.us" ), PROD( "https://api.workspaces.sidewalks.washington.edu/api/v1/workspaces", "https://tdei-gateway-prod.azurewebsites.net/api/v1", "https://tdei-usermanagement-prod.azurewebsites.net/api/v1/user-profile", - "https://osm-workspaces-proxy.azurewebsites.net/prod/api/0.6/" + "https://osm-workspaces-proxy.azurewebsites.net/prod/api/0.6/", + "https://portal.tdei.us" ); companion object { From b9fad8717aa049604a479b4b0bd428c92b539270 Mon Sep 17 00:00:00 2001 From: Rajesh Kantipudi <44539669+iamrajeshk@users.noreply.github.com> Date: Wed, 1 Jul 2026 15:30:40 +0530 Subject: [PATCH 03/19] Refactor API endpoint constants to improve clarity and consistency in environment configuration --- .../de/westnordost/streetcomplete/ApplicationModule.kt | 2 +- .../data/workspace/data/remote/WorkspaceApiService.kt | 6 +++--- .../streetcomplete/data/preferences/WorkspaceConstants.kt | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/app/src/androidMain/kotlin/de/westnordost/streetcomplete/ApplicationModule.kt b/app/src/androidMain/kotlin/de/westnordost/streetcomplete/ApplicationModule.kt index 80521876bcd..f53aef239e0 100644 --- a/app/src/androidMain/kotlin/de/westnordost/streetcomplete/ApplicationModule.kt +++ b/app/src/androidMain/kotlin/de/westnordost/streetcomplete/ApplicationModule.kt @@ -170,7 +170,7 @@ suspend fun refreshJwtToken( } val response = - tempClient.post(environmentManager.currentEnvironment.tdeiUserManagementEndpoint + "/refresh-token") { + tempClient.post(environmentManager.currentEnvironment.tdeiApiBaseUrl + "/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 20ec4a4f347..dd2060a8ffc 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.tdeiApiUrl + val url = environmentManager.currentEnvironment.tdeiUserManagementBaseurl try { val response = performHttpCallWithFirebaseTracing( @@ -107,7 +107,7 @@ class WorkspaceApiService( } suspend fun loginToWorkspace(username: String, password: String): LoginResponse { - val url = environmentManager.currentEnvironment.tdeiUserManagementEndpoint + "/authenticate" + val url = environmentManager.currentEnvironment.tdeiApiBaseUrl + "/authenticate" try { val response = performHttpCallWithFirebaseTracing( client = httpClient, @@ -145,7 +145,7 @@ class WorkspaceApiService( } suspend fun refreshToken(refreshToken: String): LoginResponse { - val url = environmentManager.currentEnvironment.tdeiUserManagementEndpoint + "/refresh-token" + val url = environmentManager.currentEnvironment.tdeiApiBaseUrl + "/refresh-token" try { val response = performHttpCallWithFirebaseTracing( diff --git a/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/preferences/WorkspaceConstants.kt b/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/preferences/WorkspaceConstants.kt index 9e726cd3717..a4a4c209044 100644 --- a/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/preferences/WorkspaceConstants.kt +++ b/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/preferences/WorkspaceConstants.kt @@ -2,8 +2,8 @@ package de.westnordost.streetcomplete.data.preferences enum class Environment( val baseUrl: String, - val tdeiUserManagementEndpoint: String, - val tdeiApiUrl: String, + val tdeiApiBaseUrl: String, + val tdeiUserManagementBaseurl: String, val osmUrl: String, val tdeiWebUrl: String, val appUpdateVersionCheckUrl: String = APP_UPDATE_VERSION_CHECKER_URL, From 3836da3bf390255d293f578087e4f7fb24cb6b18 Mon Sep 17 00:00:00 2001 From: Rajesh Kantipudi <44539669+iamrajeshk@users.noreply.github.com> Date: Thu, 2 Jul 2026 14:42:44 +0530 Subject: [PATCH 04/19] Refactor WorkspaceLoginScreen layout for improved structure and usability; add "Forgot password?" link with enhanced styling --- .../workspaces/WorkspaceLoginScreen.kt | 321 ++++++++++-------- 1 file changed, 182 insertions(+), 139 deletions(-) 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 b1b709a3097..c2bca6e7479 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 @@ -15,13 +15,16 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.ArrowDropDown import androidx.compose.material.icons.filled.Fingerprint @@ -310,13 +313,16 @@ fun LoginCard( onClick = { focusManager.clearFocus() } ) ) { - Column(verticalArrangement = Arrangement.SpaceEvenly) { + Column( + verticalArrangement = Arrangement.SpaceEvenly, + modifier = Modifier.fillMaxSize() + ) { Column( verticalArrangement = Arrangement.Center, horizontalAlignment = Alignment.CenterHorizontally, modifier = modifier .fillMaxWidth() - .fillMaxHeight(0.3f) + .weight(0.3f) .clearAndSetSemantics {} .background(MaterialTheme.colorScheme.surfaceVariant) ) { @@ -352,156 +358,186 @@ fun LoginCard( Column( horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(18.dp), modifier = modifier - .fillMaxSize() - .padding(horizontal = 48.dp) - .semantics { - contentDescription = screenTitle - } + .weight(0.7f) + .fillMaxWidth() + .imePadding() ) { - val context = LocalContext.current - var visibility by rememberSaveable { mutableStateOf(false) } - OutlinedTextField( - value = email.value, onValueChange = { newText -> email.value = newText }, - label = { - Text( - text = stringResource( - id = R.string.email - ), - color = MaterialTheme.colorScheme.onSurface, - ) - }, - keyboardOptions = KeyboardOptions( - keyboardType = KeyboardType.Email, - imeAction = ImeAction.Next - ), - trailingIcon = { - if (preferences.isBiometricEnabled) { - val coroutineScope = rememberCoroutineScope() - val creds = SecureCredentialStorage.getCredential( - LocalContext.current, - selectedEnvironment.value.name + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(18.dp), + modifier = Modifier + .weight(1f) + .fillMaxWidth() + .verticalScroll(rememberScrollState()) + .padding(horizontal = 48.dp) + .semantics { + contentDescription = screenTitle + } + ) { + val context = LocalContext.current + var visibility by rememberSaveable { mutableStateOf(false) } + OutlinedTextField( + value = email.value, onValueChange = { newText -> email.value = newText }, + label = { + Text( + text = stringResource( + id = R.string.email + ), + color = MaterialTheme.colorScheme.onSurface, ) - if (creds != null) { - IconButton(onClick = { - coroutineScope.launch { - val authenticated = authenticateWithBiometrics( - context, - activity = activity - ) - if (!authenticated) { - Toast.makeText( + }, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Email, + imeAction = ImeAction.Next + ), + trailingIcon = { + if (preferences.isBiometricEnabled) { + val coroutineScope = rememberCoroutineScope() + val creds = SecureCredentialStorage.getCredential( + LocalContext.current, + selectedEnvironment.value.name + ) + if (creds != null) { + IconButton(onClick = { + coroutineScope.launch { + val authenticated = authenticateWithBiometrics( context, - "Failed to authenticate", - Toast.LENGTH_SHORT + activity = activity ) - .show() - } else { - email.value = creds.username - password.value = creds.password - viewModel.loginToWorkspace(email.value, password.value) + if (!authenticated) { + Toast.makeText( + context, + "Failed to authenticate", + Toast.LENGTH_SHORT + ) + .show() + } else { + email.value = creds.username + password.value = creds.password + viewModel.loginToWorkspace( + email.value, + password.value + ) + } } + }) { + Icon( + imageVector = Icons.Default.Fingerprint, + contentDescription = "Login with Device Authentication" + ) } - }) { - Icon( - imageVector = Icons.Default.Fingerprint, - contentDescription = "Login with Device Authentication" - ) } } - } - }, - colors = OutlinedTextFieldDefaults.colors( - focusedLabelColor = MaterialTheme.colorScheme.onSurface, - unfocusedLabelColor = MaterialTheme.colorScheme.onSurface, - focusedTextColor = MaterialTheme.colorScheme.onSurface, - unfocusedTextColor = MaterialTheme.colorScheme.onSurface - ), - modifier = Modifier - .fillMaxWidth() - ) - OutlinedTextField( - value = password.value, - onValueChange = { newText -> password.value = newText }, - label = { - Text( - text = stringResource( - id = R.string.password - ), - color = MaterialTheme.colorScheme.onSurface - ) - }, - visualTransformation = if (visibility) VisualTransformation.None else PasswordVisualTransformation(), - keyboardOptions = KeyboardOptions( - keyboardType = KeyboardType.Password, - imeAction = ImeAction.Done - ), - trailingIcon = { - val image = - if (visibility) Icons.Default.Visibility else Icons.Default.VisibilityOff - IconButton(onClick = { visibility = !visibility }) { - Icon( - imageVector = image, - contentDescription = "Toggle password visibility" + }, + colors = OutlinedTextFieldDefaults.colors( + focusedLabelColor = MaterialTheme.colorScheme.onSurface, + unfocusedLabelColor = MaterialTheme.colorScheme.onSurface, + focusedTextColor = MaterialTheme.colorScheme.onSurface, + unfocusedTextColor = MaterialTheme.colorScheme.onSurface + ), + modifier = Modifier + .fillMaxWidth() + ) + OutlinedTextField( + value = password.value, + onValueChange = { newText -> password.value = newText }, + label = { + Text( + text = stringResource( + id = R.string.password + ), + color = MaterialTheme.colorScheme.onSurface ) - } - }, - colors = OutlinedTextFieldDefaults.colors( - focusedLabelColor = MaterialTheme.colorScheme.onSurface, - unfocusedLabelColor = MaterialTheme.colorScheme.onSurface, - focusedTextColor = MaterialTheme.colorScheme.onSurface, - unfocusedTextColor = MaterialTheme.colorScheme.onSurface - ), - modifier = Modifier - .fillMaxWidth() - ) + }, + visualTransformation = if (visibility) VisualTransformation.None else PasswordVisualTransformation(), + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Password, + imeAction = ImeAction.Done + ), + trailingIcon = { + val image = + if (visibility) Icons.Default.Visibility else Icons.Default.VisibilityOff + IconButton(onClick = { visibility = !visibility }) { + Icon( + imageVector = image, + contentDescription = "Toggle password visibility" + ) + } + }, + colors = OutlinedTextFieldDefaults.colors( + focusedLabelColor = MaterialTheme.colorScheme.onSurface, + unfocusedLabelColor = MaterialTheme.colorScheme.onSurface, + focusedTextColor = MaterialTheme.colorScheme.onSurface, + unfocusedTextColor = MaterialTheme.colorScheme.onSurface + ), + modifier = Modifier + .fillMaxWidth() + ) - Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) { - TextButton(onClick = { - // open URL in browser - val url = - selectedEnvironment.value.tdeiWebUrl + "/ForgotPassword" - val intent = Intent(Intent.ACTION_VIEW) - intent.data = url.toUri() - context.startActivity(intent) - }) { - Text("Forgot password?", color = MaterialTheme.colorScheme.primary) + if (isDebugModeEnabled) { + EnvironmentDropdownMenu(viewModel, selectedEnvironment, modifier = Modifier) } - } + Spacer(modifier = Modifier.height(8.dp)) + + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(4.dp), + modifier = Modifier.fillMaxWidth() + ) { + Button( + onClick = { + if (email.value.isEmpty() || password.value.isEmpty()) { + Toast.makeText( + context, + "Please enter email and password", + Toast.LENGTH_SHORT + ).show() + return@Button + } + viewModel.loginToWorkspace(email.value, password.value) + }, modifier = Modifier.fillMaxWidth() + ) { + Text( + text = "Login", + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.padding(vertical = 4.dp) + ) + } - if (isDebugModeEnabled) { - EnvironmentDropdownMenu(viewModel, selectedEnvironment, modifier = Modifier) - } - Button( - onClick = { - if (email.value.isEmpty() || password.value.isEmpty()) { - Toast.makeText( - context, - "Please enter email and password", - Toast.LENGTH_SHORT - ).show() - return@Button + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Center + ) { + TextButton(onClick = { + // open URL in browser + val url = + selectedEnvironment.value.tdeiWebUrl + "/ForgotPassword" + val intent = Intent(Intent.ACTION_VIEW) + intent.data = url.toUri() + context.startActivity(intent) + }) { + Text( + "Forgot password?", + style = MaterialTheme.typography.bodyLarge.copy( + color = MaterialTheme.colorScheme.primary, + fontWeight = FontWeight.Bold + ) + ) + } } - viewModel.loginToWorkspace(email.value, password.value) - }, modifier = Modifier - .fillMaxWidth() - .padding(vertical = 16.dp) - ) { - Text( - text = "Login", - style = MaterialTheme.typography.titleMedium, - modifier = Modifier.padding(vertical = 4.dp) - ) + } + Spacer(modifier = Modifier.height(24.dp)) + + UserInfoComponent() } - UserInfoComponent() + DebuggableBuild( viewModel, selectedEnvironment, preferences, modifier = modifier ) - } } } @@ -523,7 +559,10 @@ fun UserInfoComponent() { intent.data = url.toUri() context.startActivity(intent) }, - style = MaterialTheme.typography.bodyLarge.copy(color = MaterialTheme.colorScheme.primary) + style = MaterialTheme.typography.bodyLarge.copy( + color = MaterialTheme.colorScheme.primary, + fontWeight = FontWeight.Bold + ) ) Text( @@ -547,7 +586,10 @@ fun UserInfoComponent() { } } .padding(top = 16.dp), - style = MaterialTheme.typography.bodyLarge.copy(color = MaterialTheme.colorScheme.primary) + style = MaterialTheme.typography.bodyLarge.copy( + color = MaterialTheme.colorScheme.primary, + fontWeight = FontWeight.Bold + ) ) Text( @@ -559,7 +601,10 @@ fun UserInfoComponent() { intent.data = url.toUri() context.startActivity(intent) }, - style = MaterialTheme.typography.bodyLarge.copy(color = MaterialTheme.colorScheme.primary) + style = MaterialTheme.typography.bodyLarge.copy( + color = MaterialTheme.colorScheme.primary, + fontWeight = FontWeight.Bold + ) ) } } @@ -576,10 +621,8 @@ fun DebuggableBuild( var clickCount by remember { mutableIntStateOf(0) } Box( - modifier = modifier - .fillMaxWidth() - .fillMaxHeight(), - contentAlignment = Alignment.BottomCenter + modifier = modifier.fillMaxWidth(), + contentAlignment = Alignment.Center ) { Column( horizontalAlignment = Alignment.CenterHorizontally, From f9b73db9c09bd1607d11677b2344c00eef9dcc07 Mon Sep 17 00:00:00 2001 From: Rajesh Kantipudi <44539669+iamrajeshk@users.noreply.github.com> Date: Thu, 2 Jul 2026 16:01:03 +0530 Subject: [PATCH 05/19] Add content description to title in LongFormAdapter for accessibility improvements --- .../quests/sidewalk_long_form/data/LongFormAdapter.kt | 5 +++++ 1 file changed, 5 insertions(+) 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 92f4f7c7609..483dd74b7fc 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 @@ -348,6 +348,11 @@ class LongFormAdapter(val cameraIntent: () -> Unit) : fun bind(item: LongFormQuest, position: Int) { binding.title.text = item.questTitle + binding.title.contentDescription = if (allowMultiChoice) { + "${item.questTitle}. Multiple items can be selected" + } else { + "${item.questTitle}. Only one item can be selected" + } if (!item.questImageUrl.isNullOrBlank() && !preferences.isLowBandwidthModeEnabled) { binding.imageView.setImage( ImageUrl(item.questImageUrl), From f2d03569f776365f914898e60b65d0dae35b7de0 Mon Sep 17 00:00:00 2001 From: Rajesh Kantipudi <44539669+iamrajeshk@users.noreply.github.com> Date: Sun, 5 Jul 2026 17:56:39 +0530 Subject: [PATCH 06/19] Implement tag conflict resolution mechanism to handle concurrent edits; add PendingTagConflict model and controller --- .../screens/main/MainActivity.kt | 14 +- .../streetcomplete/screens/main/MainModule.kt | 2 +- .../streetcomplete/screens/main/MainScreen.kt | 14 ++ .../screens/main/MainViewModel.kt | 7 + .../screens/main/MainViewModelImpl.kt | 46 +++++- .../conflicts/TagConflictResolutionEffect.kt | 82 ++++++++++ .../data/DatabaseInitializer.kt | 10 +- .../data/osm/edits/ElementEditsModule.kt | 9 +- .../edits/update_tags/PendingTagConflict.kt | 27 ++++ .../PendingTagConflictsController.kt | 149 ++++++++++++++++++ .../update_tags/PendingTagConflictsDao.kt | 80 ++++++++++ .../update_tags/PendingTagConflictsTable.kt | 37 +++++ .../osm/edits/upload/ElementEditUploader.kt | 128 ++++++++++++++- 13 files changed, 597 insertions(+), 8 deletions(-) create mode 100644 app/src/androidMain/kotlin/de/westnordost/streetcomplete/screens/main/conflicts/TagConflictResolutionEffect.kt create mode 100644 app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/osm/edits/update_tags/PendingTagConflict.kt create mode 100644 app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/osm/edits/update_tags/PendingTagConflictsController.kt create mode 100644 app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/osm/edits/update_tags/PendingTagConflictsDao.kt create mode 100644 app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/osm/edits/update_tags/PendingTagConflictsTable.kt 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 5e2311b4c27..3c7c1f3d7c1 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 @@ -158,6 +158,7 @@ import de.westnordost.streetcomplete.util.satellite_layers.Imagery import de.westnordost.streetcomplete.util.satellite_layers.ImageryRepository import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.combine import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.koin.android.ext.android.inject @@ -1190,10 +1191,21 @@ class MainActivity : onClickImageryLayerButton() } + lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.STARTED) { + // badge shows unsynced edits plus tag conflicts still awaiting the user's + // decision, so the toolbar reflects everything that still needs attention + combine(viewModel.unsyncedEditsCount, viewModel.pendingConflictsCount) { edits, conflicts -> + edits + conflicts + }.collect { totalPendingCount -> + uploadButton.uploadableCount = totalPendingCount + } + } + } + lifecycleScope.launch { repeatOnLifecycle(Lifecycle.State.STARTED) { viewModel.unsyncedEditsCount.collect { count -> - uploadButton.uploadableCount = count uploadButton.setOnClickListener { if (count > 0) { if (viewModel.isConnected) { diff --git a/app/src/androidMain/kotlin/de/westnordost/streetcomplete/screens/main/MainModule.kt b/app/src/androidMain/kotlin/de/westnordost/streetcomplete/screens/main/MainModule.kt index d020eebf72a..21ffa69294d 100644 --- a/app/src/androidMain/kotlin/de/westnordost/streetcomplete/screens/main/MainModule.kt +++ b/app/src/androidMain/kotlin/de/westnordost/streetcomplete/screens/main/MainModule.kt @@ -15,7 +15,7 @@ val mainModule = module { viewModel { MainViewModelImpl( get(), get(), get(), get(), get(), get(), get(), get(), get(), get(), get(), get(), get(), - get(), get(), get(), get(), get(), get(), get() + get(), get(), get(), get(), get(), get(), get(), get() ) } viewModel { MultiSelectViewModel() } viewModel { EditHistoryViewModelImpl(get(), get(), get(named("FeatureDictionaryLazy"))) } diff --git a/app/src/androidMain/kotlin/de/westnordost/streetcomplete/screens/main/MainScreen.kt b/app/src/androidMain/kotlin/de/westnordost/streetcomplete/screens/main/MainScreen.kt index bf882942e02..31a06116792 100644 --- a/app/src/androidMain/kotlin/de/westnordost/streetcomplete/screens/main/MainScreen.kt +++ b/app/src/androidMain/kotlin/de/westnordost/streetcomplete/screens/main/MainScreen.kt @@ -52,6 +52,7 @@ import de.westnordost.streetcomplete.R import de.westnordost.streetcomplete.data.edithistory.Edit import de.westnordost.streetcomplete.data.edithistory.EditKey import de.westnordost.streetcomplete.data.messages.Message +import de.westnordost.streetcomplete.data.osm.edits.update_tags.PendingTagConflict import de.westnordost.streetcomplete.data.osm.geometry.ElementGeometry import de.westnordost.streetcomplete.data.osm.mapdata.BoundingBox import de.westnordost.streetcomplete.data.osm.mapdata.Element @@ -73,6 +74,7 @@ import de.westnordost.streetcomplete.screens.main.controls.PointerPinButton import de.westnordost.streetcomplete.screens.main.controls.ScaleBar import de.westnordost.streetcomplete.screens.main.controls.ZoomButtons import de.westnordost.streetcomplete.screens.main.controls.findEllipsisIntersection +import de.westnordost.streetcomplete.screens.main.conflicts.TagConflictResolutionEffect import de.westnordost.streetcomplete.screens.main.edithistory.EditHistorySidebar import de.westnordost.streetcomplete.screens.main.edithistory.EditHistoryViewModel import de.westnordost.streetcomplete.screens.main.edithistory.EditItem @@ -159,6 +161,7 @@ fun MainScreen( val hasEdits by remember { derivedStateOf { editItems.isNotEmpty() } } val showZoomButtons by viewModel.showZoomButtons.collectAsState() + val pendingConflictsCount by viewModel.pendingConflictsCount.collectAsState() var showOverlaysDropdown by remember { mutableStateOf(false) } var showTeamModeWizard by remember { mutableStateOf(false) } @@ -505,6 +508,12 @@ fun MainScreen( lastUploadError?.let { error -> LastUploadErrorEffect(lastError = error, onReportError = ::sendErrorReport) } + TagConflictResolutionEffect( + pendingConflictsCount = pendingConflictsCount, + onPopNextConflict = { viewModel.popNextConflict() }, + onResolveKeepMine = { viewModel.resolveConflictKeepMine(it) }, + onResolveKeepTheirs = { viewModel.resolveConflictKeepTheirs(it) } + ) lastCrashReport?.let { report -> LastCrashEffect(lastReport = report, onReport = { context.sendErrorReportEmail(it) }) } @@ -668,6 +677,11 @@ object PreviewMainViewModel : MainViewModel() { get() = TODO("Not yet implemented") override val isUploadingOrDownloading: StateFlow get() = MutableStateFlow(true) + override val pendingConflictsCount: StateFlow + get() = MutableStateFlow(0) + override suspend fun popNextConflict(): PendingTagConflict? = null + override suspend fun resolveConflictKeepMine(conflict: PendingTagConflict) {} + override suspend fun resolveConflictKeepTheirs(conflict: PendingTagConflict) {} override val isUserInitiatedDownloadInProgress: Boolean get() = TODO("Not yet implemented") override val isLoggedIn: StateFlow diff --git a/app/src/androidMain/kotlin/de/westnordost/streetcomplete/screens/main/MainViewModel.kt b/app/src/androidMain/kotlin/de/westnordost/streetcomplete/screens/main/MainViewModel.kt index f5faf6c8443..64ba20f2a17 100644 --- a/app/src/androidMain/kotlin/de/westnordost/streetcomplete/screens/main/MainViewModel.kt +++ b/app/src/androidMain/kotlin/de/westnordost/streetcomplete/screens/main/MainViewModel.kt @@ -3,6 +3,7 @@ package de.westnordost.streetcomplete.screens.main import androidx.compose.ui.geometry.Offset import androidx.lifecycle.ViewModel import de.westnordost.streetcomplete.data.messages.Message +import de.westnordost.streetcomplete.data.osm.edits.update_tags.PendingTagConflict import de.westnordost.streetcomplete.data.osm.mapdata.BoundingBox import de.westnordost.streetcomplete.data.overlays.Overlay import de.westnordost.streetcomplete.data.quest.QuestType @@ -58,6 +59,12 @@ abstract class MainViewModel : ViewModel() { abstract val isUploading: StateFlow abstract val isUploadingOrDownloading: StateFlow + /* tag conflicts held back instead of being discarded, to be resolved one at a time */ + abstract val pendingConflictsCount: StateFlow + abstract suspend fun popNextConflict(): PendingTagConflict? + abstract suspend fun resolveConflictKeepMine(conflict: PendingTagConflict) + abstract suspend fun resolveConflictKeepTheirs(conflict: PendingTagConflict) + abstract val isUserInitiatedDownloadInProgress: Boolean abstract val isLoggedIn: StateFlow abstract val isConnected: Boolean diff --git a/app/src/androidMain/kotlin/de/westnordost/streetcomplete/screens/main/MainViewModelImpl.kt b/app/src/androidMain/kotlin/de/westnordost/streetcomplete/screens/main/MainViewModelImpl.kt index c3161da564d..2e4879f6b05 100644 --- a/app/src/androidMain/kotlin/de/westnordost/streetcomplete/screens/main/MainViewModelImpl.kt +++ b/app/src/androidMain/kotlin/de/westnordost/streetcomplete/screens/main/MainViewModelImpl.kt @@ -11,6 +11,8 @@ import de.westnordost.streetcomplete.data.messages.MessagesSource import de.westnordost.streetcomplete.data.osm.edits.EditType import de.westnordost.streetcomplete.data.osm.edits.ElementEdit import de.westnordost.streetcomplete.data.osm.edits.ElementEditsSource +import de.westnordost.streetcomplete.data.osm.edits.update_tags.PendingTagConflict +import de.westnordost.streetcomplete.data.osm.edits.update_tags.PendingTagConflictsController import de.westnordost.streetcomplete.data.osm.mapdata.BoundingBox import de.westnordost.streetcomplete.data.osm.mapdata.LatLon import de.westnordost.streetcomplete.data.osmnotes.edits.NoteEdit @@ -72,6 +74,7 @@ class MainViewModelImpl( private val elementEditsSource: ElementEditsSource, private val noteEditsSource: NoteEditsSource, private val prefs: Preferences, + private val pendingTagConflictsController: PendingTagConflictsController, ) : MainViewModel() { /* error handling */ @@ -262,9 +265,14 @@ class MainViewModelImpl( awaitClose { downloadProgressSource.removeListener(listener) } }.stateIn(viewModelScope, SharingStarted.Eagerly, downloadProgressSource.isDownloadInProgress) + // drives the same upload/download progress indicator (isUploadingOrDownloading below) while a + // conflict resolution is in flight, instead of adding a separate spinner for it + private val isResolvingConflict = MutableStateFlow(false) + override val isUploadingOrDownloading: StateFlow = - combine(isUploading, isDownloading) { it1, it2 -> it1 || it2 } - .stateIn(viewModelScope, SharingStarted.Eagerly, false) + combine(isUploading, isDownloading, isResolvingConflict) { uploading, downloading, resolving -> + uploading || downloading || resolving + }.stateIn(viewModelScope, SharingStarted.Eagerly, false) override val isUserInitiatedDownloadInProgress: Boolean get() = downloadProgressSource.isUserInitiatedDownloadInProgress @@ -289,6 +297,40 @@ class MainViewModelImpl( } } + /* tag conflicts held back instead of being discarded */ + + override val pendingConflictsCount: StateFlow = callbackFlow { + send(pendingTagConflictsController.getCount()) + val listener = object : PendingTagConflictsController.Listener { + override fun onAdded(conflict: PendingTagConflict) { trySend(pendingTagConflictsController.getCount()) } + override fun onRemoved(conflict: PendingTagConflict) { trySend(pendingTagConflictsController.getCount()) } + } + pendingTagConflictsController.addListener(listener) + awaitClose { pendingTagConflictsController.removeListener(listener) } + }.stateIn(viewModelScope + IO, SharingStarted.Eagerly, 0) + + override suspend fun popNextConflict(): PendingTagConflict? = withContext(IO) { + pendingTagConflictsController.getOldest() + } + + override suspend fun resolveConflictKeepMine(conflict: PendingTagConflict) { + isResolvingConflict.value = true + try { + pendingTagConflictsController.resolveKeepMine(conflict) + } finally { + isResolvingConflict.value = false + } + } + + override suspend fun resolveConflictKeepTheirs(conflict: PendingTagConflict) { + isResolvingConflict.value = true + try { + pendingTagConflictsController.resolveKeepTheirs(conflict) + } finally { + isResolvingConflict.value = false + } + } + private val elementEditsListener = object : ElementEditsSource.Listener { override fun onAddedEdit(edit: ElementEdit) { launch { ensureLoggedIn() } } override fun onSyncedEdit(edit: ElementEdit) {} diff --git a/app/src/androidMain/kotlin/de/westnordost/streetcomplete/screens/main/conflicts/TagConflictResolutionEffect.kt b/app/src/androidMain/kotlin/de/westnordost/streetcomplete/screens/main/conflicts/TagConflictResolutionEffect.kt new file mode 100644 index 00000000000..18486dd85f7 --- /dev/null +++ b/app/src/androidMain/kotlin/de/westnordost/streetcomplete/screens/main/conflicts/TagConflictResolutionEffect.kt @@ -0,0 +1,82 @@ +package de.westnordost.streetcomplete.screens.main.conflicts + +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.window.DialogProperties +import de.westnordost.streetcomplete.data.osm.edits.update_tags.PendingTagConflict +import kotlinx.coroutines.launch + +/** + * Shows currently pending tag conflicts one at a time: while answering a quest, a concurrent + * remote edit changed the same OSM tag on the same element. Rather than silently discarding the + * whole answer (the old behavior), the non-conflicting part of the answer already went through - + * this only asks about the specific tag(s) that collided. + * + * Driven by [pendingConflictsCount] rather than a one-shot event so it naturally re-triggers + * (fetching the next queued conflict) whenever the previous one is resolved, giving a strictly + * one-dialog-at-a-time queue even if several conflicts piled up while the app was backgrounded. + */ +@Composable +fun TagConflictResolutionEffect( + pendingConflictsCount: Int, + onPopNextConflict: suspend () -> PendingTagConflict?, + onResolveKeepMine: suspend (PendingTagConflict) -> Unit, + onResolveKeepTheirs: suspend (PendingTagConflict) -> Unit, +) { + val scope = rememberCoroutineScope() + var current by remember { mutableStateOf(null) } + var isResolving by remember { mutableStateOf(false) } + + LaunchedEffect(pendingConflictsCount, current) { + if (current == null && pendingConflictsCount > 0) { + current = onPopNextConflict() + } + } + + val conflict = current ?: return + + fun resolve(action: suspend (PendingTagConflict) -> Unit) { + if (isResolving) return + isResolving = true + scope.launch { + action(conflict) + isResolving = false + current = null + } + } + + AlertDialog( + // must make an explicit choice - this is not just informational, it decides what ends up + // on the server + onDismissRequest = {}, + properties = DialogProperties(dismissOnBackPress = false, dismissOnClickOutside = false), + title = { Text("Someone else edited this too") }, + text = { + val mine = conflict.mineValue ?: "(removed)" + val theirs = conflict.theirsValueAtDetection ?: "(removed)" + Text( + "You answered “${conflict.tagKey} = $mine”, but someone else just set " + + "“${conflict.tagKey} = $theirs” on the same ${conflict.elementType.name.lowercase()} " + + "while you were answering. Keep your answer?" + ) + }, + confirmButton = { + TextButton(enabled = !isResolving, onClick = { resolve(onResolveKeepMine) }) { + Text("Keep mine") + } + }, + dismissButton = { + TextButton(enabled = !isResolving, onClick = { resolve(onResolveKeepTheirs) }) { + Text("Keep theirs") + } + } + ) +} diff --git a/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/DatabaseInitializer.kt b/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/DatabaseInitializer.kt index 35512450756..3d0d2bff2cf 100644 --- a/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/DatabaseInitializer.kt +++ b/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/DatabaseInitializer.kt @@ -6,6 +6,7 @@ import de.westnordost.streetcomplete.data.osm.created_elements.CreatedElementsTa import de.westnordost.streetcomplete.data.osm.edits.EditElementsTable import de.westnordost.streetcomplete.data.osm.edits.ElementEditsTable import de.westnordost.streetcomplete.data.osm.edits.ElementIdProviderTable +import de.westnordost.streetcomplete.data.osm.edits.update_tags.PendingTagConflictsTable import de.westnordost.streetcomplete.data.osm.edits.upload.changesets.OpenChangesetsTable import de.westnordost.streetcomplete.data.osm.geometry.RelationGeometryTable import de.westnordost.streetcomplete.data.osm.geometry.WayGeometryTable @@ -30,7 +31,7 @@ import de.westnordost.streetcomplete.util.logs.Log /** Creates the database and upgrades it */ object DatabaseInitializer { - const val DB_VERSION = 23 + const val DB_VERSION = 24 fun onCreate(db: Database) { // OSM notes @@ -70,6 +71,9 @@ object DatabaseInitializer { db.exec(CreatedElementsTable.CREATE) + // tag conflicts held back for the user to resolve instead of being discarded + db.exec(PendingTagConflictsTable.CREATE) + // quests db.exec(VisibleEditTypeTable.CREATE) db.exec(QuestTypeOrderTable.CREATE) @@ -305,6 +309,10 @@ object DatabaseInitializer { db.tryExec("ALTER TABLE osm_note_edits ADD COLUMN workspace_id INTEGER DEFAULT 0 NOT NULL") db.tryExec("ALTER TABLE osm_notes_hidden ADD COLUMN workspace_id INTEGER DEFAULT 0 NOT NULL") } + + if (oldVersion <= 23 && newVersion >= 24) { + db.exec(PendingTagConflictsTable.CREATE) + } } } diff --git a/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/osm/edits/ElementEditsModule.kt b/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/osm/edits/ElementEditsModule.kt index bce8c8c3c49..ece39767099 100644 --- a/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/osm/edits/ElementEditsModule.kt +++ b/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/osm/edits/ElementEditsModule.kt @@ -1,5 +1,7 @@ package de.westnordost.streetcomplete.data.osm.edits +import de.westnordost.streetcomplete.data.osm.edits.update_tags.PendingTagConflictsController +import de.westnordost.streetcomplete.data.osm.edits.update_tags.PendingTagConflictsDao import de.westnordost.streetcomplete.data.osm.edits.upload.ElementEditUploader import de.westnordost.streetcomplete.data.osm.edits.upload.ElementEditsUploader import de.westnordost.streetcomplete.data.osm.edits.upload.changesets.OpenChangesetsDao @@ -7,12 +9,13 @@ import de.westnordost.streetcomplete.data.osm.edits.upload.changesets.OpenChange import org.koin.dsl.module val elementEditsModule = module { - factory { ElementEditUploader(get(), get(), get()) } + factory { ElementEditUploader(get(), get(), get(), get()) } factory { ElementEditsDao(get(), get(), get()) } factory { ElementIdProviderDao(get()) } factory { OpenChangesetsDao(get()) } factory { EditElementsDao(get()) } + factory { PendingTagConflictsDao(get(), get(), get()) } single { OpenChangesetsManager(get(), get(), get(), get()) } @@ -21,4 +24,8 @@ val elementEditsModule = module { single { get() } single { ElementEditsController(get(), get(), get(), get()) } single { MapDataWithEditsSource(get(), get(), get()) } + + /* must be a singleton because there is a listener that should respond to a change in the + * underlying database table */ + single { PendingTagConflictsController(get(), get(), get(), get()) } } diff --git a/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/osm/edits/update_tags/PendingTagConflict.kt b/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/osm/edits/update_tags/PendingTagConflict.kt new file mode 100644 index 00000000000..1d7a90132e7 --- /dev/null +++ b/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/osm/edits/update_tags/PendingTagConflict.kt @@ -0,0 +1,27 @@ +package de.westnordost.streetcomplete.data.osm.edits.update_tags + +import de.westnordost.streetcomplete.data.osm.edits.ElementEditType +import de.westnordost.streetcomplete.data.osm.mapdata.ElementType +import de.westnordost.streetcomplete.data.osm.mapdata.LatLon + +/** A single OSM tag on a specific element where the user's local edit collided with a concurrent + * remote edit on the same key. Held here instead of being discarded together with the rest of + * the edit, so the user can decide - next time the app is in the foreground - whether to keep + * their own answer or the other edit's value. */ +data class PendingTagConflict( + var id: Long, + val elementType: ElementType, + val elementId: Long, + val tagKey: String, + /** the value the user answered locally, or null if the user's edit was to delete this tag */ + val mineValue: String?, + /** the conflicting value found on the server when the conflict was detected. Only used for + * display purposes - resolving re-fetches the current value to avoid re-racing a third edit + * that may have landed in the meantime */ + val theirsValueAtDetection: String?, + val editType: ElementEditType, + val source: String, + val position: LatLon, + val createdTimestamp: Long, + var workspaceId: Int = 0 +) diff --git a/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/osm/edits/update_tags/PendingTagConflictsController.kt b/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/osm/edits/update_tags/PendingTagConflictsController.kt new file mode 100644 index 00000000000..ea85546ba08 --- /dev/null +++ b/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/osm/edits/update_tags/PendingTagConflictsController.kt @@ -0,0 +1,149 @@ +package de.westnordost.streetcomplete.data.osm.edits.update_tags + +import de.westnordost.streetcomplete.data.ConflictException +import de.westnordost.streetcomplete.data.osm.edits.upload.changesets.OpenChangesetsManager +import de.westnordost.streetcomplete.data.osm.mapdata.Element +import de.westnordost.streetcomplete.data.osm.mapdata.ElementType +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.util.Listeners +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.IO +import kotlinx.coroutines.withContext + +/** Holds tag-level edit conflicts that were held back instead of being discarded (see + * [de.westnordost.streetcomplete.data.osm.edits.upload.ElementEditUploader]), and lets the user + * resolve them one at a time - either by re-asserting their own answer or by accepting the + * concurrent remote edit's value. */ +class PendingTagConflictsController( + private val dao: PendingTagConflictsDao, + private val mapDataApi: MapDataApiClient, + private val mapDataController: MapDataController, + private val openChangesetsManager: OpenChangesetsManager, +) { + interface Listener { + fun onAdded(conflict: PendingTagConflict) + fun onRemoved(conflict: PendingTagConflict) + } + + private val listeners = Listeners() + + fun addListener(listener: Listener) { + listeners.add(listener) + } + + fun removeListener(listener: Listener) { + listeners.remove(listener) + } + + fun add(conflict: PendingTagConflict) { + val added = dao.add(conflict) + listeners.forEach { it.onAdded(added) } + } + + fun getAll(): List = dao.getAll() + + fun getCount(): Int = dao.getCount() + + /** Returns the oldest pending conflict without removing it - it stays queryable/re-showable + * until actually resolved, so it survives the app being killed mid-decision */ + fun getOldest(): PendingTagConflict? = dao.getAll().firstOrNull() + + /** Re-assert the user's own answer for this tag, re-fetching the element fresh first so we + * don't race against a third edit that may have landed since the conflict was detected. + * Returns null if resolved (applied, or turned out to be a no-op), or an updated + * [PendingTagConflict] (with a fresh "theirs" value) if the value changed *again* in the + * meantime and the user should be asked again with up-to-date information. */ + suspend fun resolveKeepMine(conflict: PendingTagConflict): PendingTagConflict? = withContext( + Dispatchers.IO + ) { + val currentElement = fetchElement(conflict.elementType, conflict.elementId) + if (currentElement == null) { + remove(conflict) + return@withContext null + } + val currentValue = currentElement.tags[conflict.tagKey] + val change = buildChange(conflict.tagKey, conflict.mineValue, currentValue) + if (change == null) { + // already matches what the user wants (or both sides agree it should be absent) + remove(conflict) + return@withContext null + } + val updatedElement = currentElement.changesApplied(StringMapChanges(setOf(change))) + val changes = MapDataChanges(modifications = listOf(updatedElement)) + + try { + val updates = uploadWithChangesetRetry(conflict, changes) + mapDataController.updateAll(updates) + remove(conflict) + null + } catch (e: ConflictException) { + // someone changed this tag yet again while we were resolving - re-fetch and let the + // user decide again with current data rather than silently failing + val refetched = fetchElement(conflict.elementType, conflict.elementId) + if (refetched == null) { + remove(conflict) + null + } else { + remove(conflict) + val refreshed = + dao.add(conflict.copy(theirsValueAtDetection = refetched.tags[conflict.tagKey])) + listeners.forEach { it.onAdded(refreshed) } + refreshed + } + } + } + + /** Accept the concurrent remote edit's value - no network call needed, the server already + * has the value being kept */ + suspend fun resolveKeepTheirs(conflict: PendingTagConflict) = withContext(Dispatchers.IO) { + remove(conflict) + } + + private suspend fun uploadWithChangesetRetry( + conflict: PendingTagConflict, + changes: MapDataChanges, + ) = + try { + val changesetId = openChangesetsManager.getOrCreateChangeset( + conflict.editType, conflict.source, conflict.position, false + ) + mapDataApi.uploadChanges(changesetId, changes) + } catch (e: ConflictException) { + // could be a stale changeset that's been closed in the meantime - try once more with + // a fresh one before giving up and treating it as a real data conflict + val newChangesetId = openChangesetsManager.createChangeset( + conflict.editType, + conflict.source, + conflict.position + ) + mapDataApi.uploadChanges(newChangesetId, changes) + } + + private suspend fun fetchElement(type: ElementType, id: Long): Element? = when (type) { + ElementType.NODE -> mapDataApi.getNode(id) + ElementType.WAY -> mapDataApi.getWay(id) + ElementType.RELATION -> mapDataApi.getRelation(id) + } + + private fun remove(conflict: PendingTagConflict) { + if (dao.delete(conflict.id)) listeners.forEach { it.onRemoved(conflict) } + } + + private fun buildChange( + key: String, + mineValue: String?, + currentValue: String?, + ): StringMapEntryChange? = when { + mineValue != null && currentValue != null -> StringMapEntryModify( + key, + currentValue, + mineValue + ) + + mineValue != null && currentValue == null -> StringMapEntryAdd(key, mineValue) + mineValue == null && currentValue != null -> StringMapEntryDelete(key, currentValue) + else -> null + } +} diff --git a/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/osm/edits/update_tags/PendingTagConflictsDao.kt b/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/osm/edits/update_tags/PendingTagConflictsDao.kt new file mode 100644 index 00000000000..e5897ccfb46 --- /dev/null +++ b/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/osm/edits/update_tags/PendingTagConflictsDao.kt @@ -0,0 +1,80 @@ +package de.westnordost.streetcomplete.data.osm.edits.update_tags + +import de.westnordost.streetcomplete.data.AllEditTypes +import de.westnordost.streetcomplete.data.CursorPosition +import de.westnordost.streetcomplete.data.Database +import de.westnordost.streetcomplete.data.osm.edits.ElementEditType +import de.westnordost.streetcomplete.data.osm.edits.update_tags.PendingTagConflictsTable.Columns.CREATED_TIMESTAMP +import de.westnordost.streetcomplete.data.osm.edits.update_tags.PendingTagConflictsTable.Columns.ELEMENT_ID +import de.westnordost.streetcomplete.data.osm.edits.update_tags.PendingTagConflictsTable.Columns.ELEMENT_TYPE +import de.westnordost.streetcomplete.data.osm.edits.update_tags.PendingTagConflictsTable.Columns.ID +import de.westnordost.streetcomplete.data.osm.edits.update_tags.PendingTagConflictsTable.Columns.LATITUDE +import de.westnordost.streetcomplete.data.osm.edits.update_tags.PendingTagConflictsTable.Columns.LONGITUDE +import de.westnordost.streetcomplete.data.osm.edits.update_tags.PendingTagConflictsTable.Columns.MINE_VALUE +import de.westnordost.streetcomplete.data.osm.edits.update_tags.PendingTagConflictsTable.Columns.QUEST_TYPE +import de.westnordost.streetcomplete.data.osm.edits.update_tags.PendingTagConflictsTable.Columns.SOURCE +import de.westnordost.streetcomplete.data.osm.edits.update_tags.PendingTagConflictsTable.Columns.THEIRS_VALUE +import de.westnordost.streetcomplete.data.osm.edits.update_tags.PendingTagConflictsTable.Columns.WORKSPACE_ID +import de.westnordost.streetcomplete.data.osm.edits.update_tags.PendingTagConflictsTable.NAME +import de.westnordost.streetcomplete.data.osm.mapdata.ElementType +import de.westnordost.streetcomplete.data.osm.mapdata.LatLon +import de.westnordost.streetcomplete.data.preferences.Preferences + +class PendingTagConflictsDao( + private val db: Database, + private val allEditTypes: AllEditTypes, + private val preferences: Preferences? = null, +) { + private val workspaceId + get() = preferences?.workspaceId ?: 0 + + fun add(conflict: PendingTagConflict): PendingTagConflict { + val rowId = db.insert(NAME, conflict.toPairs()) + return conflict.copy(id = rowId, workspaceId = workspaceId) + } + + fun getAll(): List = + db.query( + NAME, + where = "$WORKSPACE_ID = $workspaceId", + orderBy = CREATED_TIMESTAMP + ) { it.toPendingTagConflict() } + + fun getCount(): Int = + db.queryOne( + NAME, + columns = arrayOf("COUNT(*) AS count"), + where = "$WORKSPACE_ID = $workspaceId" + ) { it.getInt("count") } ?: 0 + + fun delete(id: Long): Boolean = + db.delete(NAME, "$WORKSPACE_ID = $workspaceId AND $ID = $id") == 1 + + private fun PendingTagConflict.toPairs(): List> = listOf( + ELEMENT_TYPE to elementType.name, + ELEMENT_ID to elementId, + PendingTagConflictsTable.Columns.TAG_KEY to tagKey, + MINE_VALUE to mineValue, + THEIRS_VALUE to theirsValueAtDetection, + QUEST_TYPE to editType.name, + SOURCE to source, + LATITUDE to position.latitude, + LONGITUDE to position.longitude, + CREATED_TIMESTAMP to createdTimestamp, + WORKSPACE_ID to workspaceId + ) + + private fun CursorPosition.toPendingTagConflict() = PendingTagConflict( + id = getLong(ID), + elementType = ElementType.valueOf(getString(ELEMENT_TYPE)), + elementId = getLong(ELEMENT_ID), + tagKey = getString(PendingTagConflictsTable.Columns.TAG_KEY), + mineValue = getStringOrNull(MINE_VALUE), + theirsValueAtDetection = getStringOrNull(THEIRS_VALUE), + editType = allEditTypes.getByName(getString(QUEST_TYPE)) as ElementEditType, + source = getString(SOURCE), + position = LatLon(getDouble(LATITUDE), getDouble(LONGITUDE)), + createdTimestamp = getLong(CREATED_TIMESTAMP), + workspaceId = getInt(WORKSPACE_ID) + ) +} diff --git a/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/osm/edits/update_tags/PendingTagConflictsTable.kt b/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/osm/edits/update_tags/PendingTagConflictsTable.kt new file mode 100644 index 00000000000..d36bb6349ac --- /dev/null +++ b/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/osm/edits/update_tags/PendingTagConflictsTable.kt @@ -0,0 +1,37 @@ +package de.westnordost.streetcomplete.data.osm.edits.update_tags + +object PendingTagConflictsTable { + const val NAME = "osm_pending_tag_conflicts" + + object Columns { + const val ID = "id" + const val ELEMENT_TYPE = "element_type" + const val ELEMENT_ID = "element_id" + const val TAG_KEY = "tag_key" + const val MINE_VALUE = "mine_value" + const val THEIRS_VALUE = "theirs_value" + const val QUEST_TYPE = "quest_type" + const val SOURCE = "source" + const val LATITUDE = "latitude" + const val LONGITUDE = "longitude" + const val CREATED_TIMESTAMP = "created" + const val WORKSPACE_ID = "workspace_id" + } + + const val CREATE = """ + CREATE TABLE $NAME ( + ${Columns.ID} INTEGER PRIMARY KEY AUTOINCREMENT, + ${Columns.ELEMENT_TYPE} varchar(255) NOT NULL, + ${Columns.ELEMENT_ID} int NOT NULL, + ${Columns.TAG_KEY} varchar(255) NOT NULL, + ${Columns.MINE_VALUE} varchar(255), + ${Columns.THEIRS_VALUE} varchar(255), + ${Columns.QUEST_TYPE} varchar(255) NOT NULL, + ${Columns.SOURCE} varchar(255) NOT NULL, + ${Columns.LATITUDE} double NOT NULL, + ${Columns.LONGITUDE} double NOT NULL, + ${Columns.CREATED_TIMESTAMP} int NOT NULL, + ${Columns.WORKSPACE_ID} int NOT NULL + ); + """ +} diff --git a/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/osm/edits/upload/ElementEditUploader.kt b/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/osm/edits/upload/ElementEditUploader.kt index ab225e64903..548d68eeeb7 100644 --- a/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/osm/edits/upload/ElementEditUploader.kt +++ b/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/osm/edits/upload/ElementEditUploader.kt @@ -5,18 +5,32 @@ import de.westnordost.streetcomplete.ApplicationConstants.EDIT_ACTIONS_NOT_ALLOW 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.update_tags.PendingTagConflict +import de.westnordost.streetcomplete.data.osm.edits.update_tags.PendingTagConflictsController +import de.westnordost.streetcomplete.data.osm.edits.update_tags.StringMapChanges +import de.westnordost.streetcomplete.data.osm.edits.update_tags.StringMapEntryAdd +import de.westnordost.streetcomplete.data.osm.edits.update_tags.StringMapEntryChange +import de.westnordost.streetcomplete.data.osm.edits.update_tags.StringMapEntryDelete +import de.westnordost.streetcomplete.data.osm.edits.update_tags.StringMapEntryModify +import de.westnordost.streetcomplete.data.osm.edits.update_tags.UpdateElementTagsAction +import de.westnordost.streetcomplete.data.osm.edits.update_tags.changesApplied +import de.westnordost.streetcomplete.data.osm.edits.update_tags.isGeometrySubstantiallyDifferent import de.westnordost.streetcomplete.data.osm.edits.upload.changesets.OpenChangesetsManager import de.westnordost.streetcomplete.data.osm.mapdata.ChangesetTooLargeException +import de.westnordost.streetcomplete.data.osm.mapdata.Element +import de.westnordost.streetcomplete.data.osm.mapdata.ElementType 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 +import de.westnordost.streetcomplete.util.ktx.nowAsEpochMilliseconds class ElementEditUploader( private val changesetManager: OpenChangesetsManager, private val mapDataApi: MapDataApiClient, - private val mapDataController: MapDataController + private val mapDataController: MapDataController, + private val pendingTagConflictsController: PendingTagConflictsController, ) { /** Apply the given change to the given element and upload it @@ -60,9 +74,16 @@ class ElementEditUploader( * @throws ConflictException if element has been changed on remote in an incompatible way * */ private suspend fun uploadUsingRemoteRepo(edit: ElementEdit, getIdProvider: () -> ElementIdProvider): MapDataUpdates { + val action = edit.action + // tag-only edits get special handling: a colliding key doesn't have to sink the whole + // edit, see uploadTagChangesUsingRemoteRepo + if (action is UpdateElementTagsAction) { + return uploadTagChangesUsingRemoteRepo(edit, action) + } + // If a conflict is thrown here, it definitely means that the element has been changed on // remote in an incompatible way. So, we don't catch the exception but exit - val remoteChanges = edit.action.createUpdates(RemoteMapDataRepository(mapDataApi), getIdProvider()) + val remoteChanges = action.createUpdates(RemoteMapDataRepository(mapDataApi), getIdProvider()) return try { uploadChanges(edit, remoteChanges, false) @@ -77,6 +98,86 @@ class ElementEditUploader( } } + /** + * Applies a tag-update edit onto the element's current (freshly fetched) remote state, but + * unlike other edit types, a per-key value collision does not discard the whole edit: + * - keys that don't collide with the current remote tags are merged and uploaded right away + * - the two bookkeeping keys are always force-overwritten with the local value, never treated + * as a conflict + * - any other genuinely colliding key is held back as a [PendingTagConflict] for the user to + * resolve later, instead of losing the whole batch of answers + * + * Structural issues (element deleted, geometry changed substantially) are still a hard, + * all-or-nothing failure - there's no sensible per-tag override for those. + */ + private suspend fun uploadTagChangesUsingRemoteRepo(edit: ElementEdit, action: UpdateElementTagsAction): MapDataUpdates { + val original = action.originalElement + val currentElement = fetchElement(original.type, original.id) + ?: throw ConflictException("Element deleted") + + if (isGeometrySubstantiallyDifferent(original, currentElement)) { + throw ConflictException("Element geometry changed substantially") + } + + // bookkeeping tags are never shown to the user as a conflict - rebuild them against the + // element's current value so they never register as colliding and always win with the + // local (i.e. most recent) value + val reconciledChanges = action.changes.changes.map { change -> + if (change.key in SILENTLY_OVERRIDDEN_TAG_KEYS) { + change.rebuiltAgainst(currentElement.tags[change.key]) + } else { + change + } + }.toSet() + + val realConflicts = StringMapChanges(reconciledChanges).getConflictsTo(currentElement.tags).toSet() + val safeChanges = StringMapChanges(reconciledChanges - realConflicts) + + val updates = if (safeChanges.isEmpty()) { + // nothing could be merged - just refresh the local cache to the current server state + MapDataUpdates(updated = listOf(currentElement)) + } else { + val updatedElement = currentElement.changesApplied(safeChanges) + val changes = MapDataChanges(modifications = listOf(updatedElement)) + try { + uploadChanges(edit, changes, false) + } + // probably changeset was closed -> try again once with new changeset + catch (e: ConflictException) { + uploadChanges(edit, changes, true) + } + catch (e: ChangesetTooLargeException) { + uploadChanges(edit, changes, true) + } + } + + for (conflict in realConflicts) { + pendingTagConflictsController.add( + PendingTagConflict( + id = 0, + elementType = currentElement.type, + elementId = currentElement.id, + tagKey = conflict.key, + mineValue = conflict.mineValue(), + theirsValueAtDetection = currentElement.tags[conflict.key], + editType = edit.type, + source = edit.source, + position = edit.position, + createdTimestamp = nowAsEpochMilliseconds(), + workspaceId = edit.workspaceId + ) + ) + } + + return updates + } + + private suspend fun fetchElement(type: ElementType, id: Long): Element? = when (type) { + ElementType.NODE -> mapDataApi.getNode(id) + ElementType.WAY -> mapDataApi.getWay(id) + ElementType.RELATION -> mapDataApi.getRelation(id) + } + private suspend fun uploadChanges( edit: ElementEdit, changes: MapDataChanges, @@ -89,4 +190,27 @@ class ElementEditUploader( } return mapDataApi.uploadChanges(changesetId, changes, ApplicationConstants::ignoreRelation) } + + companion object { + /** tags that are set on every long-form answer purely for bookkeeping/quest-visibility + * purposes - never worth bothering the user with a conflict prompt about, see + * EditDescription.kt which excludes the same two keys from edit-history display */ + private val SILENTLY_OVERRIDDEN_TAG_KEYS = setOf("ext:gig_complete", "ext:gig_last_updated") + } +} + +private fun StringMapEntryChange.mineValue(): String? = when (this) { + is StringMapEntryAdd -> value + is StringMapEntryModify -> value + is StringMapEntryDelete -> null +} + +private fun StringMapEntryChange.rebuiltAgainst(currentValue: String?): StringMapEntryChange { + val mine = mineValue() + return when { + mine != null && currentValue != null -> StringMapEntryModify(key, currentValue, mine) + mine != null && currentValue == null -> StringMapEntryAdd(key, mine) + mine == null && currentValue != null -> StringMapEntryDelete(key, currentValue) + else -> this + } } From 6a9f0fc47a9153b878621291f2ecd9974ebcc4ac Mon Sep 17 00:00:00 2001 From: Rajesh Kantipudi <44539669+iamrajeshk@users.noreply.github.com> Date: Sun, 5 Jul 2026 18:08:54 +0530 Subject: [PATCH 07/19] Enhance WorkspaceViewModelImpl to include DownloadedTilesController for improved tile management; add logic to invalidate downloaded tiles on workspace switch --- .../streetcomplete/data/workspace/WorkspaceModule.kt | 2 +- .../streetcomplete/screens/workspaces/WorkspaceViewModel.kt | 6 ++++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/app/src/androidMain/kotlin/de/westnordost/streetcomplete/data/workspace/WorkspaceModule.kt b/app/src/androidMain/kotlin/de/westnordost/streetcomplete/data/workspace/WorkspaceModule.kt index 71934f94651..a78d5b07557 100644 --- a/app/src/androidMain/kotlin/de/westnordost/streetcomplete/data/workspace/WorkspaceModule.kt +++ b/app/src/androidMain/kotlin/de/westnordost/streetcomplete/data/workspace/WorkspaceModule.kt @@ -15,6 +15,6 @@ val workspaceModule = module { // single { GetWorkspaceUseCase(get()) } // single { LoginUseCase(get()) } - viewModel { WorkspaceViewModelImpl(get(), get()) } + viewModel { WorkspaceViewModelImpl(get(), get(), get()) } } diff --git a/app/src/androidMain/kotlin/de/westnordost/streetcomplete/screens/workspaces/WorkspaceViewModel.kt b/app/src/androidMain/kotlin/de/westnordost/streetcomplete/screens/workspaces/WorkspaceViewModel.kt index 385ff245e35..148ecb409c7 100644 --- a/app/src/androidMain/kotlin/de/westnordost/streetcomplete/screens/workspaces/WorkspaceViewModel.kt +++ b/app/src/androidMain/kotlin/de/westnordost/streetcomplete/screens/workspaces/WorkspaceViewModel.kt @@ -4,6 +4,7 @@ import android.location.Location import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import de.westnordost.streetcomplete.BuildConfig +import de.westnordost.streetcomplete.data.download.tiles.DownloadedTilesController import de.westnordost.streetcomplete.data.elementfilter.ParseException import de.westnordost.streetcomplete.data.elementfilter.toElementFilterExpression import de.westnordost.streetcomplete.data.preferences.Environment @@ -56,6 +57,7 @@ abstract class WorkspaceViewModel : ViewModel() { class WorkspaceViewModelImpl( private val workspaceRepository: WorkspaceRepository, private val preferences: Preferences, + private val downloadedTilesController: DownloadedTilesController, ) : WorkspaceViewModel() { val isLoggedIn: Boolean = preferences.workspaceLogin @@ -74,6 +76,10 @@ class WorkspaceViewModelImpl( _selectedWorkspace.value = (showWorkspaces.value as WorkspaceListState.Success).workspaces .filter { it.externalAppAccess == 1 && it.type == "osw" }[index] preferences.workspaceId = _selectedWorkspace.value?.id + // the "has this area already been downloaded" bookkeeping isn't scoped per workspace, so + // without this, switching workspaces while looking at the same map area makes auto-download + // wrongly think the new workspace's data is already fresh and skip fetching it + downloadedTilesController.invalidateAll() } private var userLocation: Location? = null From b9ba84b00642a3efaf7c4a381b2aed84937eb16f Mon Sep 17 00:00:00 2001 From: Rajesh Kantipudi <44539669+iamrajeshk@users.noreply.github.com> Date: Mon, 6 Jul 2026 19:04:45 +0530 Subject: [PATCH 08/19] Enhance WorkspaceViewModelImpl to include DownloadedTilesController for improved tile management; add logic to invalidate downloaded tiles on workspace switch --- .../streetcomplete/quests/ALongForm.kt | 8 +++ .../data/LongFormAdapter.kt | 60 +++++++++++++++---- .../quests/sidewalk_long_form/data/Quest.kt | 15 +++++ 3 files changed, 71 insertions(+), 12 deletions(-) diff --git a/app/src/androidMain/kotlin/de/westnordost/streetcomplete/quests/ALongForm.kt b/app/src/androidMain/kotlin/de/westnordost/streetcomplete/quests/ALongForm.kt index 0472c7c3b76..3ec5a1d0c64 100644 --- a/app/src/androidMain/kotlin/de/westnordost/streetcomplete/quests/ALongForm.kt +++ b/app/src/androidMain/kotlin/de/westnordost/streetcomplete/quests/ALongForm.kt @@ -9,6 +9,7 @@ import android.view.inputmethod.InputMethodManager import android.widget.Toast import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView +import androidx.recyclerview.widget.SimpleItemAnimator import de.westnordost.streetcomplete.R import de.westnordost.streetcomplete.databinding.QuestLongFormListBinding import de.westnordost.streetcomplete.quests.sidewalk_long_form.data.LongFormAdapter @@ -74,6 +75,13 @@ abstract class ALongForm : AbstractOsmQuestForm() { super.onViewCreated(view, savedInstanceState) binding.recyclerView.apply { layoutManager = LinearLayoutManager(activity) + // DiffUtil-driven partial updates (see LongFormAdapter.items) now issue targeted + // notifyItemChanged() calls instead of notifyDataSetChanged(). The default item + // animator runs a cross-fade "change" animation on those, which reads as flicker on + // image content - notifyDataSetChanged() never triggered that. Keep insert/remove/move + // animations (questions appearing/disappearing still animates nicely), just drop the + // content-change cross-fade. + (itemAnimator as? SimpleItemAnimator)?.supportsChangeAnimations = false } setVisibilityOfItems() binding.recyclerView.adapter = adapter 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 483dd74b7fc..16b13d1fcb3 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 @@ -15,6 +15,7 @@ import android.widget.TextView import androidx.core.graphics.drawable.toDrawable import androidx.core.view.ViewCompat import androidx.core.view.accessibility.AccessibilityNodeInfoCompat +import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.GridLayoutManager import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView.ViewHolder @@ -49,8 +50,12 @@ class LongFormAdapter(val cameraIntent: () -> Unit) : }.flatten() } - field = manageVisibility(value).filter { it.visible } - notifyDataSetChanged() + // snapshot each item rather than keeping the live (mutated-in-place) instances, so the + // diff below compares genuinely independent before/after values - see LongFormQuest.snapshot() + val newList = manageVisibility(value).filter { it.visible }.map { it.snapshot() } + val diff = DiffUtil.calculateDiff(LongFormQuestDiffCallback(field, newList)) + field = newList + diff.dispatchUpdatesTo(this) } enum class ViewType(val value: Int) { @@ -66,6 +71,8 @@ class LongFormAdapter(val cameraIntent: () -> Unit) : } private fun manageVisibility(itemCopy: List): List { + val byQuestId = itemCopy.associateBy { it.questId } + for (quest in itemCopy) { val dependencies = quest.questAnswerDependency ?: emptyList() var isVisible = true @@ -76,7 +83,7 @@ class LongFormAdapter(val cameraIntent: () -> Unit) : if (requiredUserInput == null || requiredQuestId == null) continue - val filteredQuest = itemCopy.find { it.questId == requiredQuestId } + val filteredQuest = byQuestId[requiredQuestId] if (filteredQuest != null) { when (filteredQuest.userInput) { is UserInput.Single -> { @@ -101,7 +108,7 @@ class LongFormAdapter(val cameraIntent: () -> Unit) : } } } - itemCopy[itemCopy.indexOf(quest)].visible = isVisible + quest.visible = isVisible } itemCopy.forEach { @@ -112,6 +119,22 @@ class LongFormAdapter(val cameraIntent: () -> Unit) : return itemCopy } + /** Diffs two snapshots of the visible question list so only rows that actually appeared, + * disappeared, moved or changed content get rebound - not the whole list every time. */ + private class LongFormQuestDiffCallback( + private val oldList: List, + private val newList: List, + ) : DiffUtil.Callback() { + override fun getOldListSize() = oldList.size + override fun getNewListSize() = newList.size + + override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int) = + oldList[oldItemPosition].questId == newList[newItemPosition].questId + + override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int) = + oldList[oldItemPosition] == newList[newItemPosition] + } + class DefaultViewHolder(val binding: CellLongFormItemBinding) : ViewHolder(binding.root) { fun bind(item: LongFormQuest) { if (item.visible) binding.container.visibility = @@ -339,6 +362,12 @@ class LongFormAdapter(val cameraIntent: () -> Unit) : ImageSelectAdapter(if (allowMultiChoice) -1 else 1) private var selectionListener: ImageSelectAdapter.OnItemSelectionListener? = null + // the choices for a given question never change once loaded, so this lets bind() skip + // rebuilding imageSelectAdapter.items (and the image reloads that would trigger) when this + // row was rebound for a reason unrelated to its own choices, e.g. another question's answer + // changing this row's position or an unrelated selection elsewhere in the form + private var boundQuestId: Int? = null + init { binding.list.layoutManager = GridLayoutManager(binding.root.context, 3) binding.list.isNestedScrollingEnabled = false @@ -457,14 +486,21 @@ class LongFormAdapter(val cameraIntent: () -> Unit) : imageSelectAdapter.listeners.add(listener) selectionListener = listener - imageSelectAdapter.items = item.questAnswerChoices?.map { - Item2( - item, - ImageUrl(it?.imageUrl), - CharSequenceText(it?.choiceText!!), - CharSequenceText("") - ) - }!! + if (item.questId != boundQuestId) { + boundQuestId = item.questId + imageSelectAdapter.items = item.questAnswerChoices?.map { + Item2( + item, + ImageUrl(it?.imageUrl), + CharSequenceText(it?.choiceText!!), + CharSequenceText("") + ) + }!! + } else { + // choices are unchanged, so the items setter above (and its own notifyDataSetChanged) + // was skipped - refresh explicitly so the selected/deselected highlight still updates + imageSelectAdapter.notifyDataSetChanged() + } } fun handleDeselection( diff --git a/app/src/androidMain/kotlin/de/westnordost/streetcomplete/quests/sidewalk_long_form/data/Quest.kt b/app/src/androidMain/kotlin/de/westnordost/streetcomplete/quests/sidewalk_long_form/data/Quest.kt index d7771aad98d..e7cad19ba3f 100644 --- a/app/src/androidMain/kotlin/de/westnordost/streetcomplete/quests/sidewalk_long_form/data/Quest.kt +++ b/app/src/androidMain/kotlin/de/westnordost/streetcomplete/quests/sidewalk_long_form/data/Quest.kt @@ -46,3 +46,18 @@ sealed class UserInput : Parcelable { } } } + +/** A frozen copy of this quest's current state, safe to keep around and compare against later - + * [LongFormQuest.selectedIndex] and [UserInput.Multiple.answers] are mutated in place elsewhere + * (`.add()`/`.remove()`), so a shallow `.copy()` alone would still share those mutable lists with + * the live, later-mutated instance. Used to give DiffUtil genuinely independent before/after + * values to compare (see LongFormAdapter). */ +fun LongFormQuest.snapshot(): LongFormQuest = copy( + selectedIndex = selectedIndex?.toMutableList(), + userInput = userInput?.snapshot() +) + +fun UserInput.snapshot(): UserInput = when (this) { + is UserInput.Single -> copy() + is UserInput.Multiple -> copy(answers = answers.toMutableList()) +} From 9be341f657d1c5f5a0ce9802d1634331ba47c63f Mon Sep 17 00:00:00 2001 From: Rajesh Kantipudi <44539669+iamrajeshk@users.noreply.github.com> Date: Mon, 6 Jul 2026 21:30:27 +0530 Subject: [PATCH 09/19] Implement DiscardedEditNotices functionality to manage unsalvageable edit conflicts; update database schema and ViewModel for tracking discarded notices --- .../screens/main/MainActivity.kt | 13 +++++++---- .../streetcomplete/screens/main/MainModule.kt | 2 +- .../streetcomplete/screens/main/MainScreen.kt | 12 ++++++++++ .../screens/main/MainViewModel.kt | 6 +++++ .../screens/main/MainViewModelImpl.kt | 23 +++++++++++++++++++ .../data/DatabaseInitializer.kt | 10 +++++++- .../data/osm/edits/ElementEditsModule.kt | 4 +++- .../osm/edits/upload/ElementEditsUploader.kt | 15 +++++++++++- 8 files changed, 77 insertions(+), 8 deletions(-) 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 3c7c1f3d7c1..95c19a8a392 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 @@ -1193,10 +1193,15 @@ class MainActivity : lifecycleScope.launch { repeatOnLifecycle(Lifecycle.State.STARTED) { - // badge shows unsynced edits plus tag conflicts still awaiting the user's - // decision, so the toolbar reflects everything that still needs attention - combine(viewModel.unsyncedEditsCount, viewModel.pendingConflictsCount) { edits, conflicts -> - edits + conflicts + // badge shows unsynced edits, tag conflicts still awaiting the user's decision, + // and notices about discarded edits still to be acknowledged - everything that + // still needs attention + combine( + viewModel.unsyncedEditsCount, + viewModel.pendingConflictsCount, + viewModel.discardedNoticesCount + ) { edits, conflicts, discarded -> + edits + conflicts + discarded }.collect { totalPendingCount -> uploadButton.uploadableCount = totalPendingCount } diff --git a/app/src/androidMain/kotlin/de/westnordost/streetcomplete/screens/main/MainModule.kt b/app/src/androidMain/kotlin/de/westnordost/streetcomplete/screens/main/MainModule.kt index 21ffa69294d..f8e054b6ddd 100644 --- a/app/src/androidMain/kotlin/de/westnordost/streetcomplete/screens/main/MainModule.kt +++ b/app/src/androidMain/kotlin/de/westnordost/streetcomplete/screens/main/MainModule.kt @@ -15,7 +15,7 @@ val mainModule = module { viewModel { MainViewModelImpl( get(), get(), get(), get(), get(), get(), get(), get(), get(), get(), get(), get(), get(), - get(), get(), get(), get(), get(), get(), get(), get() + get(), get(), get(), get(), get(), get(), get(), get(), get() ) } viewModel { MultiSelectViewModel() } viewModel { EditHistoryViewModelImpl(get(), get(), get(named("FeatureDictionaryLazy"))) } diff --git a/app/src/androidMain/kotlin/de/westnordost/streetcomplete/screens/main/MainScreen.kt b/app/src/androidMain/kotlin/de/westnordost/streetcomplete/screens/main/MainScreen.kt index 31a06116792..6ef91f288f9 100644 --- a/app/src/androidMain/kotlin/de/westnordost/streetcomplete/screens/main/MainScreen.kt +++ b/app/src/androidMain/kotlin/de/westnordost/streetcomplete/screens/main/MainScreen.kt @@ -52,6 +52,7 @@ import de.westnordost.streetcomplete.R import de.westnordost.streetcomplete.data.edithistory.Edit import de.westnordost.streetcomplete.data.edithistory.EditKey import de.westnordost.streetcomplete.data.messages.Message +import de.westnordost.streetcomplete.data.osm.edits.DiscardedEditNotice import de.westnordost.streetcomplete.data.osm.edits.update_tags.PendingTagConflict import de.westnordost.streetcomplete.data.osm.geometry.ElementGeometry import de.westnordost.streetcomplete.data.osm.mapdata.BoundingBox @@ -74,6 +75,7 @@ import de.westnordost.streetcomplete.screens.main.controls.PointerPinButton import de.westnordost.streetcomplete.screens.main.controls.ScaleBar import de.westnordost.streetcomplete.screens.main.controls.ZoomButtons import de.westnordost.streetcomplete.screens.main.controls.findEllipsisIntersection +import de.westnordost.streetcomplete.screens.main.conflicts.DiscardedEditNoticeEffect import de.westnordost.streetcomplete.screens.main.conflicts.TagConflictResolutionEffect import de.westnordost.streetcomplete.screens.main.edithistory.EditHistorySidebar import de.westnordost.streetcomplete.screens.main.edithistory.EditHistoryViewModel @@ -162,6 +164,7 @@ fun MainScreen( val showZoomButtons by viewModel.showZoomButtons.collectAsState() val pendingConflictsCount by viewModel.pendingConflictsCount.collectAsState() + val discardedNoticesCount by viewModel.discardedNoticesCount.collectAsState() var showOverlaysDropdown by remember { mutableStateOf(false) } var showTeamModeWizard by remember { mutableStateOf(false) } @@ -514,6 +517,11 @@ fun MainScreen( onResolveKeepMine = { viewModel.resolveConflictKeepMine(it) }, onResolveKeepTheirs = { viewModel.resolveConflictKeepTheirs(it) } ) + DiscardedEditNoticeEffect( + discardedNoticesCount = discardedNoticesCount, + onPopNextDiscardedNotice = { viewModel.popNextDiscardedNotice() }, + onDismissDiscardedNotice = { viewModel.dismissDiscardedNotice(it) } + ) lastCrashReport?.let { report -> LastCrashEffect(lastReport = report, onReport = { context.sendErrorReportEmail(it) }) } @@ -682,6 +690,10 @@ object PreviewMainViewModel : MainViewModel() { override suspend fun popNextConflict(): PendingTagConflict? = null override suspend fun resolveConflictKeepMine(conflict: PendingTagConflict) {} override suspend fun resolveConflictKeepTheirs(conflict: PendingTagConflict) {} + override val discardedNoticesCount: StateFlow + get() = MutableStateFlow(0) + override suspend fun popNextDiscardedNotice(): DiscardedEditNotice? = null + override suspend fun dismissDiscardedNotice(notice: DiscardedEditNotice) {} override val isUserInitiatedDownloadInProgress: Boolean get() = TODO("Not yet implemented") override val isLoggedIn: StateFlow diff --git a/app/src/androidMain/kotlin/de/westnordost/streetcomplete/screens/main/MainViewModel.kt b/app/src/androidMain/kotlin/de/westnordost/streetcomplete/screens/main/MainViewModel.kt index 64ba20f2a17..959e8fee644 100644 --- a/app/src/androidMain/kotlin/de/westnordost/streetcomplete/screens/main/MainViewModel.kt +++ b/app/src/androidMain/kotlin/de/westnordost/streetcomplete/screens/main/MainViewModel.kt @@ -3,6 +3,7 @@ package de.westnordost.streetcomplete.screens.main import androidx.compose.ui.geometry.Offset import androidx.lifecycle.ViewModel import de.westnordost.streetcomplete.data.messages.Message +import de.westnordost.streetcomplete.data.osm.edits.DiscardedEditNotice import de.westnordost.streetcomplete.data.osm.edits.update_tags.PendingTagConflict import de.westnordost.streetcomplete.data.osm.mapdata.BoundingBox import de.westnordost.streetcomplete.data.overlays.Overlay @@ -65,6 +66,11 @@ abstract class MainViewModel : ViewModel() { abstract suspend fun resolveConflictKeepMine(conflict: PendingTagConflict) abstract suspend fun resolveConflictKeepTheirs(conflict: PendingTagConflict) + /* notices about edits discarded due to an unsalvageable conflict, to be acknowledged one at a time */ + abstract val discardedNoticesCount: StateFlow + abstract suspend fun popNextDiscardedNotice(): DiscardedEditNotice? + abstract suspend fun dismissDiscardedNotice(notice: DiscardedEditNotice) + abstract val isUserInitiatedDownloadInProgress: Boolean abstract val isLoggedIn: StateFlow abstract val isConnected: Boolean diff --git a/app/src/androidMain/kotlin/de/westnordost/streetcomplete/screens/main/MainViewModelImpl.kt b/app/src/androidMain/kotlin/de/westnordost/streetcomplete/screens/main/MainViewModelImpl.kt index 2e4879f6b05..bd69d34a0f6 100644 --- a/app/src/androidMain/kotlin/de/westnordost/streetcomplete/screens/main/MainViewModelImpl.kt +++ b/app/src/androidMain/kotlin/de/westnordost/streetcomplete/screens/main/MainViewModelImpl.kt @@ -8,6 +8,8 @@ import de.westnordost.streetcomplete.data.download.DownloadController import de.westnordost.streetcomplete.data.download.DownloadProgressSource import de.westnordost.streetcomplete.data.messages.Message import de.westnordost.streetcomplete.data.messages.MessagesSource +import de.westnordost.streetcomplete.data.osm.edits.DiscardedEditNotice +import de.westnordost.streetcomplete.data.osm.edits.DiscardedEditNoticesController import de.westnordost.streetcomplete.data.osm.edits.EditType import de.westnordost.streetcomplete.data.osm.edits.ElementEdit import de.westnordost.streetcomplete.data.osm.edits.ElementEditsSource @@ -75,6 +77,7 @@ class MainViewModelImpl( private val noteEditsSource: NoteEditsSource, private val prefs: Preferences, private val pendingTagConflictsController: PendingTagConflictsController, + private val discardedEditNoticesController: DiscardedEditNoticesController, ) : MainViewModel() { /* error handling */ @@ -331,6 +334,26 @@ class MainViewModelImpl( } } + /* notices about edits discarded due to an unsalvageable conflict */ + + override val discardedNoticesCount: StateFlow = callbackFlow { + send(discardedEditNoticesController.getCount()) + val listener = object : DiscardedEditNoticesController.Listener { + override fun onAdded(notice: DiscardedEditNotice) { trySend(discardedEditNoticesController.getCount()) } + override fun onRemoved(notice: DiscardedEditNotice) { trySend(discardedEditNoticesController.getCount()) } + } + discardedEditNoticesController.addListener(listener) + awaitClose { discardedEditNoticesController.removeListener(listener) } + }.stateIn(viewModelScope + IO, SharingStarted.Eagerly, 0) + + override suspend fun popNextDiscardedNotice(): DiscardedEditNotice? = withContext(IO) { + discardedEditNoticesController.getOldest() + } + + override suspend fun dismissDiscardedNotice(notice: DiscardedEditNotice) = withContext(IO) { + discardedEditNoticesController.dismiss(notice) + } + private val elementEditsListener = object : ElementEditsSource.Listener { override fun onAddedEdit(edit: ElementEdit) { launch { ensureLoggedIn() } } override fun onSyncedEdit(edit: ElementEdit) {} diff --git a/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/DatabaseInitializer.kt b/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/DatabaseInitializer.kt index 3d0d2bff2cf..dc5513ed5a7 100644 --- a/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/DatabaseInitializer.kt +++ b/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/DatabaseInitializer.kt @@ -3,6 +3,7 @@ package de.westnordost.streetcomplete.data import de.westnordost.streetcomplete.data.download.tiles.DownloadedTilesTable import de.westnordost.streetcomplete.data.logs.LogsTable import de.westnordost.streetcomplete.data.osm.created_elements.CreatedElementsTable +import de.westnordost.streetcomplete.data.osm.edits.DiscardedEditNoticesTable import de.westnordost.streetcomplete.data.osm.edits.EditElementsTable import de.westnordost.streetcomplete.data.osm.edits.ElementEditsTable import de.westnordost.streetcomplete.data.osm.edits.ElementIdProviderTable @@ -31,7 +32,7 @@ import de.westnordost.streetcomplete.util.logs.Log /** Creates the database and upgrades it */ object DatabaseInitializer { - const val DB_VERSION = 24 + const val DB_VERSION = 25 fun onCreate(db: Database) { // OSM notes @@ -74,6 +75,9 @@ object DatabaseInitializer { // tag conflicts held back for the user to resolve instead of being discarded db.exec(PendingTagConflictsTable.CREATE) + // notices about edits that had to be discarded (unsalvageable structural conflicts) + db.exec(DiscardedEditNoticesTable.CREATE) + // quests db.exec(VisibleEditTypeTable.CREATE) db.exec(QuestTypeOrderTable.CREATE) @@ -313,6 +317,10 @@ object DatabaseInitializer { if (oldVersion <= 23 && newVersion >= 24) { db.exec(PendingTagConflictsTable.CREATE) } + + if (oldVersion <= 24 && newVersion >= 25) { + db.exec(DiscardedEditNoticesTable.CREATE) + } } } diff --git a/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/osm/edits/ElementEditsModule.kt b/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/osm/edits/ElementEditsModule.kt index ece39767099..259dba75e67 100644 --- a/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/osm/edits/ElementEditsModule.kt +++ b/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/osm/edits/ElementEditsModule.kt @@ -16,10 +16,11 @@ val elementEditsModule = module { factory { OpenChangesetsDao(get()) } factory { EditElementsDao(get()) } factory { PendingTagConflictsDao(get(), get(), get()) } + factory { DiscardedEditNoticesDao(get(), get(), get()) } single { OpenChangesetsManager(get(), get(), get(), get()) } - single { ElementEditsUploader(get(), get(), get(), get(), get(), get()) } + single { ElementEditsUploader(get(), get(), get(), get(), get(), get(), get()) } single { get() } single { ElementEditsController(get(), get(), get(), get()) } @@ -28,4 +29,5 @@ val elementEditsModule = module { /* must be a singleton because there is a listener that should respond to a change in the * underlying database table */ single { PendingTagConflictsController(get(), get(), get(), get()) } + single { DiscardedEditNoticesController(get()) } } diff --git a/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/osm/edits/upload/ElementEditsUploader.kt b/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/osm/edits/upload/ElementEditsUploader.kt index 077c2219d9d..733dd754c64 100644 --- a/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/osm/edits/upload/ElementEditsUploader.kt +++ b/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/osm/edits/upload/ElementEditsUploader.kt @@ -1,6 +1,8 @@ package de.westnordost.streetcomplete.data.osm.edits.upload import de.westnordost.streetcomplete.data.ConflictException +import de.westnordost.streetcomplete.data.osm.edits.DiscardedEditNotice +import de.westnordost.streetcomplete.data.osm.edits.DiscardedEditNoticesController import de.westnordost.streetcomplete.data.osm.edits.ElementEdit import de.westnordost.streetcomplete.data.osm.edits.ElementEditsController import de.westnordost.streetcomplete.data.osm.edits.ElementIdProvider @@ -16,6 +18,7 @@ import de.westnordost.streetcomplete.data.osm.mapdata.MutableMapData import de.westnordost.streetcomplete.data.osmnotes.edits.NoteEditsController import de.westnordost.streetcomplete.data.upload.OnUploadedChangeListener import de.westnordost.streetcomplete.data.user.statistics.StatisticsController +import de.westnordost.streetcomplete.util.ktx.nowAsEpochMilliseconds import de.westnordost.streetcomplete.util.logs.Log import kotlinx.coroutines.CoroutineName import kotlinx.coroutines.CoroutineScope @@ -32,7 +35,8 @@ class ElementEditsUploader( private val mapDataController: MapDataController, private val singleUploader: ElementEditUploader, private val mapDataApi: MapDataApiClient, - private val statisticsController: StatisticsController + private val statisticsController: StatisticsController, + private val discardedEditNoticesController: DiscardedEditNoticesController, ) { var uploadedChangeListener: OnUploadedChangeListener? = null @@ -73,6 +77,15 @@ class ElementEditsUploader( uploadedChangeListener?.onDiscarded(edit.type.name, edit.position) elementEditsController.markSyncFailed(edit) + discardedEditNoticesController.add( + DiscardedEditNotice( + id = 0, + editType = edit.type, + position = edit.position, + reason = e.message ?: "Could not be applied to the current state of the map", + createdTimestamp = nowAsEpochMilliseconds() + ) + ) /* fetching the current version of the element(s) edited on conflict and persisting them is not really optional, as when the edit has been deleted due to the conflict, From 9a40bf486cb131949f1eba8ccd8663f1e7d3d17a Mon Sep 17 00:00:00 2001 From: Rajesh Kantipudi <44539669+iamrajeshk@users.noreply.github.com> Date: Mon, 6 Jul 2026 21:32:43 +0530 Subject: [PATCH 10/19] Implement DiscardedEditNotice mechanism to surface unsalvageable edit conflicts; add database schema, DAO, and UI effect for user notifications --- .../conflicts/DiscardedEditNoticeEffect.kt | 69 +++++++++++++++++++ .../data/osm/edits/DiscardedEditNotice.kt | 17 +++++ .../edits/DiscardedEditNoticesController.kt | 37 ++++++++++ .../data/osm/edits/DiscardedEditNoticesDao.kt | 64 +++++++++++++++++ .../osm/edits/DiscardedEditNoticesTable.kt | 27 ++++++++ 5 files changed, 214 insertions(+) create mode 100644 app/src/androidMain/kotlin/de/westnordost/streetcomplete/screens/main/conflicts/DiscardedEditNoticeEffect.kt create mode 100644 app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/osm/edits/DiscardedEditNotice.kt create mode 100644 app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/osm/edits/DiscardedEditNoticesController.kt create mode 100644 app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/osm/edits/DiscardedEditNoticesDao.kt create mode 100644 app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/osm/edits/DiscardedEditNoticesTable.kt diff --git a/app/src/androidMain/kotlin/de/westnordost/streetcomplete/screens/main/conflicts/DiscardedEditNoticeEffect.kt b/app/src/androidMain/kotlin/de/westnordost/streetcomplete/screens/main/conflicts/DiscardedEditNoticeEffect.kt new file mode 100644 index 00000000000..95ad4e38cec --- /dev/null +++ b/app/src/androidMain/kotlin/de/westnordost/streetcomplete/screens/main/conflicts/DiscardedEditNoticeEffect.kt @@ -0,0 +1,69 @@ +package de.westnordost.streetcomplete.screens.main.conflicts + +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import de.westnordost.streetcomplete.data.osm.edits.DiscardedEditNotice +import kotlinx.coroutines.launch + +/** + * Shows notices about edits that had to be discarded one at a time: the underlying element was + * deleted or changed too substantially in the meantime for the answer to still apply. Unlike + * [TagConflictResolutionEffect], there is nothing to decide here - the quest already disappeared + * from the map when it was answered, and without this, the user would only find out something + * went wrong much later when the quest silently reappears with no explanation. + * + * Driven by [discardedNoticesCount] rather than a one-shot event, same as [TagConflictResolutionEffect], + * so it naturally re-triggers and shows the next queued notice, one dialog at a time. + */ +@Composable +fun DiscardedEditNoticeEffect( + discardedNoticesCount: Int, + onPopNextDiscardedNotice: suspend () -> DiscardedEditNotice?, + onDismissDiscardedNotice: suspend (DiscardedEditNotice) -> Unit, +) { + val scope = rememberCoroutineScope() + var current by remember { mutableStateOf(null) } + var isDismissing by remember { mutableStateOf(false) } + + LaunchedEffect(discardedNoticesCount, current) { + if (current == null && discardedNoticesCount > 0) { + current = onPopNextDiscardedNotice() + } + } + + val notice = current ?: return + + fun dismiss() { + if (isDismissing) return + isDismissing = true + scope.launch { + onDismissDiscardedNotice(notice) + isDismissing = false + current = null + } + } + + AlertDialog( + onDismissRequest = ::dismiss, + title = { Text("Answer could not be saved") }, + text = { + Text( + "Your answer for ${notice.editType.name} near here could not be saved: " + + "${notice.reason}. The quest may show up again." + ) + }, + confirmButton = { + TextButton(enabled = !isDismissing, onClick = ::dismiss) { + Text("OK") + } + } + ) +} diff --git a/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/osm/edits/DiscardedEditNotice.kt b/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/osm/edits/DiscardedEditNotice.kt new file mode 100644 index 00000000000..5e539d8e9c5 --- /dev/null +++ b/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/osm/edits/DiscardedEditNotice.kt @@ -0,0 +1,17 @@ +package de.westnordost.streetcomplete.data.osm.edits + +import de.westnordost.streetcomplete.data.osm.mapdata.LatLon + +/** A local edit that could not be applied because the underlying element was deleted or changed + * too substantially in the meantime (see ElementEditsUploader). Unlike a [de.westnordost.streetcomplete.data.osm.edits.update_tags.PendingTagConflict], + * there is nothing to resolve here - the edit is unsalvageable - so this is purely informational: + * it lets the user find out their answer was discarded instead of the quest just silently + * reappearing later with no explanation. */ +data class DiscardedEditNotice( + var id: Long, + val editType: ElementEditType, + val position: LatLon, + val reason: String, + val createdTimestamp: Long, + var workspaceId: Int = 0 +) diff --git a/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/osm/edits/DiscardedEditNoticesController.kt b/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/osm/edits/DiscardedEditNoticesController.kt new file mode 100644 index 00000000000..34d270b368e --- /dev/null +++ b/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/osm/edits/DiscardedEditNoticesController.kt @@ -0,0 +1,37 @@ +package de.westnordost.streetcomplete.data.osm.edits + +import de.westnordost.streetcomplete.util.Listeners + +/** Holds notices about edits that had to be discarded because the underlying element was deleted + * or changed too substantially in the meantime (see ElementEditsUploader) - purely informational, + * there is nothing to resolve, unlike [de.westnordost.streetcomplete.data.osm.edits.update_tags.PendingTagConflictsController]. + * Lets the user find out an answer was lost instead of the quest just silently reappearing later. */ +class DiscardedEditNoticesController( + private val dao: DiscardedEditNoticesDao, +) { + interface Listener { + fun onAdded(notice: DiscardedEditNotice) + fun onRemoved(notice: DiscardedEditNotice) + } + private val listeners = Listeners() + + fun addListener(listener: Listener) { listeners.add(listener) } + fun removeListener(listener: Listener) { listeners.remove(listener) } + + fun add(notice: DiscardedEditNotice) { + val added = dao.add(notice) + listeners.forEach { it.onAdded(added) } + } + + fun getAll(): List = dao.getAll() + + fun getCount(): Int = dao.getCount() + + /** Returns the oldest notice without removing it - it stays queryable/re-showable until + * actually dismissed, so it survives the app being killed before the user sees it */ + fun getOldest(): DiscardedEditNotice? = dao.getAll().firstOrNull() + + fun dismiss(notice: DiscardedEditNotice) { + if (dao.delete(notice.id)) listeners.forEach { it.onRemoved(notice) } + } +} diff --git a/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/osm/edits/DiscardedEditNoticesDao.kt b/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/osm/edits/DiscardedEditNoticesDao.kt new file mode 100644 index 00000000000..09648fe7e22 --- /dev/null +++ b/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/osm/edits/DiscardedEditNoticesDao.kt @@ -0,0 +1,64 @@ +package de.westnordost.streetcomplete.data.osm.edits + +import de.westnordost.streetcomplete.data.AllEditTypes +import de.westnordost.streetcomplete.data.CursorPosition +import de.westnordost.streetcomplete.data.Database +import de.westnordost.streetcomplete.data.osm.edits.DiscardedEditNoticesTable.Columns.CREATED_TIMESTAMP +import de.westnordost.streetcomplete.data.osm.edits.DiscardedEditNoticesTable.Columns.ID +import de.westnordost.streetcomplete.data.osm.edits.DiscardedEditNoticesTable.Columns.LATITUDE +import de.westnordost.streetcomplete.data.osm.edits.DiscardedEditNoticesTable.Columns.LONGITUDE +import de.westnordost.streetcomplete.data.osm.edits.DiscardedEditNoticesTable.Columns.QUEST_TYPE +import de.westnordost.streetcomplete.data.osm.edits.DiscardedEditNoticesTable.Columns.REASON +import de.westnordost.streetcomplete.data.osm.edits.DiscardedEditNoticesTable.Columns.WORKSPACE_ID +import de.westnordost.streetcomplete.data.osm.edits.DiscardedEditNoticesTable.NAME +import de.westnordost.streetcomplete.data.osm.mapdata.LatLon +import de.westnordost.streetcomplete.data.preferences.Preferences + +class DiscardedEditNoticesDao( + private val db: Database, + private val allEditTypes: AllEditTypes, + private val preferences: Preferences? = null, +) { + private val workspaceId + get() = preferences?.workspaceId ?: 0 + + fun add(notice: DiscardedEditNotice): DiscardedEditNotice { + val rowId = db.insert(NAME, notice.toPairs()) + return notice.copy(id = rowId, workspaceId = workspaceId) + } + + fun getAll(): List = + db.query( + NAME, + where = "$WORKSPACE_ID = $workspaceId", + orderBy = CREATED_TIMESTAMP + ) { it.toDiscardedEditNotice() } + + fun getCount(): Int = + db.queryOne( + NAME, + columns = arrayOf("COUNT(*) AS count"), + where = "$WORKSPACE_ID = $workspaceId" + ) { it.getInt("count") } ?: 0 + + fun delete(id: Long): Boolean = + db.delete(NAME, "$WORKSPACE_ID = $workspaceId AND $ID = $id") == 1 + + private fun DiscardedEditNotice.toPairs(): List> = listOf( + QUEST_TYPE to editType.name, + REASON to reason, + LATITUDE to position.latitude, + LONGITUDE to position.longitude, + CREATED_TIMESTAMP to createdTimestamp, + WORKSPACE_ID to workspaceId + ) + + private fun CursorPosition.toDiscardedEditNotice() = DiscardedEditNotice( + id = getLong(ID), + editType = allEditTypes.getByName(getString(QUEST_TYPE)) as ElementEditType, + reason = getString(REASON), + position = LatLon(getDouble(LATITUDE), getDouble(LONGITUDE)), + createdTimestamp = getLong(CREATED_TIMESTAMP), + workspaceId = getInt(WORKSPACE_ID) + ) +} diff --git a/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/osm/edits/DiscardedEditNoticesTable.kt b/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/osm/edits/DiscardedEditNoticesTable.kt new file mode 100644 index 00000000000..c9636faaf7a --- /dev/null +++ b/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/osm/edits/DiscardedEditNoticesTable.kt @@ -0,0 +1,27 @@ +package de.westnordost.streetcomplete.data.osm.edits + +object DiscardedEditNoticesTable { + const val NAME = "osm_discarded_edit_notices" + + object Columns { + const val ID = "id" + const val QUEST_TYPE = "quest_type" + const val REASON = "reason" + const val LATITUDE = "latitude" + const val LONGITUDE = "longitude" + const val CREATED_TIMESTAMP = "created" + const val WORKSPACE_ID = "workspace_id" + } + + const val CREATE = """ + CREATE TABLE $NAME ( + ${Columns.ID} INTEGER PRIMARY KEY AUTOINCREMENT, + ${Columns.QUEST_TYPE} varchar(255) NOT NULL, + ${Columns.REASON} varchar(255) NOT NULL, + ${Columns.LATITUDE} double NOT NULL, + ${Columns.LONGITUDE} double NOT NULL, + ${Columns.CREATED_TIMESTAMP} int NOT NULL, + ${Columns.WORKSPACE_ID} int NOT NULL + ); + """ +} From 42ab19fcd2702faae46aa2d7d310d66caf2aa654 Mon Sep 17 00:00:00 2001 From: Rajesh Kantipudi <44539669+iamrajeshk@users.noreply.github.com> Date: Mon, 6 Jul 2026 23:06:32 +0530 Subject: [PATCH 11/19] Refactor tag conflict resolution to handle groups of conflicts; update methods and UI to display multiple conflicts in a single dialog --- .../streetcomplete/screens/main/MainScreen.kt | 4 +- .../screens/main/MainViewModel.kt | 2 +- .../screens/main/MainViewModelImpl.kt | 4 +- .../conflicts/TagConflictResolutionEffect.kt | 95 ++++++++++++------- .../PendingTagConflictsController.kt | 13 ++- 5 files changed, 79 insertions(+), 39 deletions(-) diff --git a/app/src/androidMain/kotlin/de/westnordost/streetcomplete/screens/main/MainScreen.kt b/app/src/androidMain/kotlin/de/westnordost/streetcomplete/screens/main/MainScreen.kt index 6ef91f288f9..c46f625753d 100644 --- a/app/src/androidMain/kotlin/de/westnordost/streetcomplete/screens/main/MainScreen.kt +++ b/app/src/androidMain/kotlin/de/westnordost/streetcomplete/screens/main/MainScreen.kt @@ -513,7 +513,7 @@ fun MainScreen( } TagConflictResolutionEffect( pendingConflictsCount = pendingConflictsCount, - onPopNextConflict = { viewModel.popNextConflict() }, + onPopNextConflictGroup = { viewModel.popNextConflictGroup() }, onResolveKeepMine = { viewModel.resolveConflictKeepMine(it) }, onResolveKeepTheirs = { viewModel.resolveConflictKeepTheirs(it) } ) @@ -687,7 +687,7 @@ object PreviewMainViewModel : MainViewModel() { get() = MutableStateFlow(true) override val pendingConflictsCount: StateFlow get() = MutableStateFlow(0) - override suspend fun popNextConflict(): PendingTagConflict? = null + override suspend fun popNextConflictGroup(): List = emptyList() override suspend fun resolveConflictKeepMine(conflict: PendingTagConflict) {} override suspend fun resolveConflictKeepTheirs(conflict: PendingTagConflict) {} override val discardedNoticesCount: StateFlow diff --git a/app/src/androidMain/kotlin/de/westnordost/streetcomplete/screens/main/MainViewModel.kt b/app/src/androidMain/kotlin/de/westnordost/streetcomplete/screens/main/MainViewModel.kt index 959e8fee644..2f17ef1432e 100644 --- a/app/src/androidMain/kotlin/de/westnordost/streetcomplete/screens/main/MainViewModel.kt +++ b/app/src/androidMain/kotlin/de/westnordost/streetcomplete/screens/main/MainViewModel.kt @@ -62,7 +62,7 @@ abstract class MainViewModel : ViewModel() { /* tag conflicts held back instead of being discarded, to be resolved one at a time */ abstract val pendingConflictsCount: StateFlow - abstract suspend fun popNextConflict(): PendingTagConflict? + abstract suspend fun popNextConflictGroup(): List abstract suspend fun resolveConflictKeepMine(conflict: PendingTagConflict) abstract suspend fun resolveConflictKeepTheirs(conflict: PendingTagConflict) diff --git a/app/src/androidMain/kotlin/de/westnordost/streetcomplete/screens/main/MainViewModelImpl.kt b/app/src/androidMain/kotlin/de/westnordost/streetcomplete/screens/main/MainViewModelImpl.kt index bd69d34a0f6..3ff7bb028c0 100644 --- a/app/src/androidMain/kotlin/de/westnordost/streetcomplete/screens/main/MainViewModelImpl.kt +++ b/app/src/androidMain/kotlin/de/westnordost/streetcomplete/screens/main/MainViewModelImpl.kt @@ -312,8 +312,8 @@ class MainViewModelImpl( awaitClose { pendingTagConflictsController.removeListener(listener) } }.stateIn(viewModelScope + IO, SharingStarted.Eagerly, 0) - override suspend fun popNextConflict(): PendingTagConflict? = withContext(IO) { - pendingTagConflictsController.getOldest() + override suspend fun popNextConflictGroup(): List = withContext(IO) { + pendingTagConflictsController.getOldestGroup() } override suspend fun resolveConflictKeepMine(conflict: PendingTagConflict) { diff --git a/app/src/androidMain/kotlin/de/westnordost/streetcomplete/screens/main/conflicts/TagConflictResolutionEffect.kt b/app/src/androidMain/kotlin/de/westnordost/streetcomplete/screens/main/conflicts/TagConflictResolutionEffect.kt index 18486dd85f7..4144ed6c392 100644 --- a/app/src/androidMain/kotlin/de/westnordost/streetcomplete/screens/main/conflicts/TagConflictResolutionEffect.kt +++ b/app/src/androidMain/kotlin/de/westnordost/streetcomplete/screens/main/conflicts/TagConflictResolutionEffect.kt @@ -1,55 +1,75 @@ package de.westnordost.streetcomplete.screens.main.conflicts +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Checkbox import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateMapOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp import androidx.compose.ui.window.DialogProperties import de.westnordost.streetcomplete.data.osm.edits.update_tags.PendingTagConflict import kotlinx.coroutines.launch /** - * Shows currently pending tag conflicts one at a time: while answering a quest, a concurrent - * remote edit changed the same OSM tag on the same element. Rather than silently discarding the - * whole answer (the old behavior), the non-conflicting part of the answer already went through - - * this only asks about the specific tag(s) that collided. + * Shows all pending tag conflicts *for one element* together, in a single dialog, instead of one + * dialog per conflicting tag: while answering a quest, a concurrent remote edit changed some of + * the same OSM tags on the same element. Rather than silently discarding the whole answer (the + * old behavior), the non-conflicting part already went through - this only asks about the + * specific tag(s) that collided, and lets the user decide per tag whether to keep their own answer + * (checked, the default) or accept the other edit's value (unchecked). * * Driven by [pendingConflictsCount] rather than a one-shot event so it naturally re-triggers - * (fetching the next queued conflict) whenever the previous one is resolved, giving a strictly - * one-dialog-at-a-time queue even if several conflicts piled up while the app was backgrounded. + * (fetching the next element's group of conflicts) whenever the current one is applied, giving a + * strictly one-dialog-at-a-time queue even if several elements' conflicts piled up while the app + * was backgrounded. */ @Composable fun TagConflictResolutionEffect( pendingConflictsCount: Int, - onPopNextConflict: suspend () -> PendingTagConflict?, + onPopNextConflictGroup: suspend () -> List, onResolveKeepMine: suspend (PendingTagConflict) -> Unit, onResolveKeepTheirs: suspend (PendingTagConflict) -> Unit, ) { val scope = rememberCoroutineScope() - var current by remember { mutableStateOf(null) } - var isResolving by remember { mutableStateOf(false) } + var currentGroup by remember { mutableStateOf>(emptyList()) } + var isApplying by remember { mutableStateOf(false) } + val keepMine = remember { mutableStateMapOf() } - LaunchedEffect(pendingConflictsCount, current) { - if (current == null && pendingConflictsCount > 0) { - current = onPopNextConflict() + LaunchedEffect(pendingConflictsCount, currentGroup) { + if (currentGroup.isEmpty() && pendingConflictsCount > 0) { + val group = onPopNextConflictGroup() + keepMine.clear() + // default to keeping the user's own answer - they answered these, assume they still + // want them unless they uncheck one + group.forEach { keepMine[it.id] = true } + currentGroup = group } } - val conflict = current ?: return + val group = currentGroup + if (group.isEmpty()) return - fun resolve(action: suspend (PendingTagConflict) -> Unit) { - if (isResolving) return - isResolving = true + fun apply() { + if (isApplying) return + isApplying = true scope.launch { - action(conflict) - isResolving = false - current = null + for (conflict in group) { + if (keepMine[conflict.id] == true) onResolveKeepMine(conflict) else onResolveKeepTheirs(conflict) + } + isApplying = false + currentGroup = emptyList() } } @@ -60,22 +80,31 @@ fun TagConflictResolutionEffect( properties = DialogProperties(dismissOnBackPress = false, dismissOnClickOutside = false), title = { Text("Someone else edited this too") }, text = { - val mine = conflict.mineValue ?: "(removed)" - val theirs = conflict.theirsValueAtDetection ?: "(removed)" - Text( - "You answered “${conflict.tagKey} = $mine”, but someone else just set " + - "“${conflict.tagKey} = $theirs” on the same ${conflict.elementType.name.lowercase()} " + - "while you were answering. Keep your answer?" - ) - }, - confirmButton = { - TextButton(enabled = !isResolving, onClick = { resolve(onResolveKeepMine) }) { - Text("Keep mine") + Column { + Text( + "While you were answering, someone else changed the following on the same " + + "${group.first().elementType.name.lowercase()}. Keep your answer for each?" + ) + group.forEach { conflict -> + val mine = conflict.mineValue ?: "(removed)" + val theirs = conflict.theirsValueAtDetection ?: "(removed)" + Row( + modifier = Modifier.padding(top = 12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Checkbox( + enabled = !isApplying, + checked = keepMine[conflict.id] ?: true, + onCheckedChange = { keepMine[conflict.id] = it } + ) + Text("${conflict.tagKey}: you said “$mine”, they set “$theirs”") + } + } } }, - dismissButton = { - TextButton(enabled = !isResolving, onClick = { resolve(onResolveKeepTheirs) }) { - Text("Keep theirs") + confirmButton = { + TextButton(enabled = !isApplying, onClick = ::apply) { + Text("Apply") } } ) diff --git a/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/osm/edits/update_tags/PendingTagConflictsController.kt b/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/osm/edits/update_tags/PendingTagConflictsController.kt index ea85546ba08..9251288b478 100644 --- a/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/osm/edits/update_tags/PendingTagConflictsController.kt +++ b/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/osm/edits/update_tags/PendingTagConflictsController.kt @@ -44,12 +44,23 @@ class PendingTagConflictsController( fun getAll(): List = dao.getAll() - fun getCount(): Int = dao.getCount() + /** Number of elements with at least one pending conflict, not the number of conflicting tags - + * matches the grouped-by-element dialog (see TagConflictResolutionEffect), where several + * conflicting tags on the same element are shown and resolved together as one */ + fun getCount(): Int = dao.getAll().distinctBy { it.elementType to it.elementId }.size /** Returns the oldest pending conflict without removing it - it stays queryable/re-showable * until actually resolved, so it survives the app being killed mid-decision */ fun getOldest(): PendingTagConflict? = dao.getAll().firstOrNull() + /** All pending conflicts for the same element as the oldest one, so they can be shown - and + * decided on - together in a single dialog instead of one at a time */ + fun getOldestGroup(): List { + val all = dao.getAll() + val oldest = all.firstOrNull() ?: return emptyList() + return all.filter { it.elementType == oldest.elementType && it.elementId == oldest.elementId } + } + /** Re-assert the user's own answer for this tag, re-fetching the element fresh first so we * don't race against a third edit that may have landed since the conflict was detected. * Returns null if resolved (applied, or turned out to be a no-op), or an updated From 5e78ce5d667acb429939fc5d7e752b5ee63a7c9e Mon Sep 17 00:00:00 2001 From: Rajesh Kantipudi <44539669+iamrajeshk@users.noreply.github.com> Date: Mon, 6 Jul 2026 23:14:28 +0530 Subject: [PATCH 12/19] Refactor createQuestChanges method to accept element and geometry parameters for improved flexibility in tag updates --- .../streetcomplete/quests/AbstractOsmQuestForm.kt | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) 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 6d217ea7ab9..ff1c5e95437 100644 --- a/app/src/androidMain/kotlin/de/westnordost/streetcomplete/quests/AbstractOsmQuestForm.kt +++ b/app/src/androidMain/kotlin/de/westnordost/streetcomplete/quests/AbstractOsmQuestForm.kt @@ -332,7 +332,7 @@ abstract class AbstractOsmQuestForm : AbstractQuestForm(), IsShowingQuestDeta solve( UpdateElementTagsAction( element.first, - createQuestChanges(answer, extraTagList) + createQuestChanges(answer, extraTagList, element.first, element.second) ), element.second ) } @@ -352,10 +352,12 @@ abstract class AbstractOsmQuestForm : AbstractQuestForm(), IsShowingQuestDeta private fun createQuestChanges( answer: T, extraTagList: MutableList> = mutableListOf(), + forElement: Element = element, + forGeometry: ElementGeometry = geometry, ): StringMapChanges { - val changesBuilder = StringMapChangesBuilder(element.tags) + val changesBuilder = StringMapChangesBuilder(forElement.tags) extraTagList.forEach { changesBuilder[it.first] = it.second } - osmElementQuestType.applyAnswerTo(answer, changesBuilder, geometry, element.timestampEdited) + osmElementQuestType.applyAnswerTo(answer, changesBuilder, forGeometry, forElement.timestampEdited) val changes = changesBuilder.create() require(!changes.isEmpty()) { "${osmElementQuestType.name} was answered by the user but there are no changes!" From c4b1e5197a8628a7a1780fc3ae1e75370bcc8b26 Mon Sep 17 00:00:00 2001 From: Rajesh Kantipudi <44539669+iamrajeshk@users.noreply.github.com> Date: Tue, 7 Jul 2026 16:36:36 +0530 Subject: [PATCH 13/19] Enhance DiscardedEditNotice and related components to include element type and ID for better conflict resolution context; update UI to display element labels in dialogs --- .../streetcomplete/quests/AbstractOsmQuestForm.kt | 6 +++++- .../streetcomplete/screens/main/MainScreen.kt | 8 ++++++-- .../streetcomplete/screens/main/MainViewModel.kt | 5 +++++ .../streetcomplete/screens/main/MainViewModelImpl.kt | 4 ++++ .../main/conflicts/DiscardedEditNoticeEffect.kt | 12 ++++++++++-- .../main/conflicts/TagConflictResolutionEffect.kt | 9 +++++++-- .../data/osm/edits/DiscardedEditNotice.kt | 5 +++++ .../data/osm/edits/DiscardedEditNoticesDao.kt | 7 +++++++ .../data/osm/edits/DiscardedEditNoticesTable.kt | 4 ++++ .../data/osm/edits/upload/ElementEditsUploader.kt | 3 +++ 10 files changed, 56 insertions(+), 7 deletions(-) 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 ff1c5e95437..ba10da5be55 100644 --- a/app/src/androidMain/kotlin/de/westnordost/streetcomplete/quests/AbstractOsmQuestForm.kt +++ b/app/src/androidMain/kotlin/de/westnordost/streetcomplete/quests/AbstractOsmQuestForm.kt @@ -192,7 +192,11 @@ abstract class AbstractOsmQuestForm : AbstractQuestForm(), IsShowingQuestDeta super.onViewCreated(view, savedInstanceState) if (osmElementQuestType is AddGenericLong) { - setTitle((osmElementQuestType as AddGenericLong).item.elementType) + val category = (osmElementQuestType as AddGenericLong).item.elementType + // the category alone ("Sidewalk", "Kerb"...) doesn't distinguish between several + // queued quests of the same type - add the OSM element type and id + val typeAndId = "${element.type.name.lowercase().replaceFirstChar { it.uppercase() }} #${element.id}" + setTitle("$category — $typeAndId") } setHideQuestOnClick { hideQuest() } diff --git a/app/src/androidMain/kotlin/de/westnordost/streetcomplete/screens/main/MainScreen.kt b/app/src/androidMain/kotlin/de/westnordost/streetcomplete/screens/main/MainScreen.kt index c46f625753d..af2df9718ed 100644 --- a/app/src/androidMain/kotlin/de/westnordost/streetcomplete/screens/main/MainScreen.kt +++ b/app/src/androidMain/kotlin/de/westnordost/streetcomplete/screens/main/MainScreen.kt @@ -57,6 +57,7 @@ import de.westnordost.streetcomplete.data.osm.edits.update_tags.PendingTagConfli import de.westnordost.streetcomplete.data.osm.geometry.ElementGeometry import de.westnordost.streetcomplete.data.osm.mapdata.BoundingBox import de.westnordost.streetcomplete.data.osm.mapdata.Element +import de.westnordost.streetcomplete.data.osm.mapdata.ElementType import de.westnordost.streetcomplete.data.overlays.Overlay import de.westnordost.streetcomplete.data.quest.QuestType import de.westnordost.streetcomplete.data.urlconfig.UrlConfig @@ -515,12 +516,14 @@ fun MainScreen( pendingConflictsCount = pendingConflictsCount, onPopNextConflictGroup = { viewModel.popNextConflictGroup() }, onResolveKeepMine = { viewModel.resolveConflictKeepMine(it) }, - onResolveKeepTheirs = { viewModel.resolveConflictKeepTheirs(it) } + onResolveKeepTheirs = { viewModel.resolveConflictKeepTheirs(it) }, + onGetElementLabel = { type, id -> viewModel.getElementLabel(type, id) } ) DiscardedEditNoticeEffect( discardedNoticesCount = discardedNoticesCount, onPopNextDiscardedNotice = { viewModel.popNextDiscardedNotice() }, - onDismissDiscardedNotice = { viewModel.dismissDiscardedNotice(it) } + onDismissDiscardedNotice = { viewModel.dismissDiscardedNotice(it) }, + onGetElementLabel = { type, id -> viewModel.getElementLabel(type, id) } ) lastCrashReport?.let { report -> LastCrashEffect(lastReport = report, onReport = { context.sendErrorReportEmail(it) }) @@ -694,6 +697,7 @@ object PreviewMainViewModel : MainViewModel() { get() = MutableStateFlow(0) override suspend fun popNextDiscardedNotice(): DiscardedEditNotice? = null override suspend fun dismissDiscardedNotice(notice: DiscardedEditNotice) {} + override suspend fun getElementLabel(type: ElementType, id: Long): String? = null override val isUserInitiatedDownloadInProgress: Boolean get() = TODO("Not yet implemented") override val isLoggedIn: StateFlow diff --git a/app/src/androidMain/kotlin/de/westnordost/streetcomplete/screens/main/MainViewModel.kt b/app/src/androidMain/kotlin/de/westnordost/streetcomplete/screens/main/MainViewModel.kt index 2f17ef1432e..c2f5a324fb3 100644 --- a/app/src/androidMain/kotlin/de/westnordost/streetcomplete/screens/main/MainViewModel.kt +++ b/app/src/androidMain/kotlin/de/westnordost/streetcomplete/screens/main/MainViewModel.kt @@ -6,6 +6,7 @@ import de.westnordost.streetcomplete.data.messages.Message import de.westnordost.streetcomplete.data.osm.edits.DiscardedEditNotice import de.westnordost.streetcomplete.data.osm.edits.update_tags.PendingTagConflict import de.westnordost.streetcomplete.data.osm.mapdata.BoundingBox +import de.westnordost.streetcomplete.data.osm.mapdata.ElementType import de.westnordost.streetcomplete.data.overlays.Overlay import de.westnordost.streetcomplete.data.quest.QuestType import de.westnordost.streetcomplete.data.urlconfig.UrlConfig @@ -71,6 +72,10 @@ abstract class MainViewModel : ViewModel() { abstract suspend fun popNextDiscardedNotice(): DiscardedEditNotice? abstract suspend fun dismissDiscardedNotice(notice: DiscardedEditNotice) + /** A short, human-readable label identifying an element (id, and name/ref if it has one), for + * display in the conflict/discard dialogs so the user can recognize which feature they're about */ + abstract suspend fun getElementLabel(type: ElementType, id: Long): String? + abstract val isUserInitiatedDownloadInProgress: Boolean abstract val isLoggedIn: StateFlow abstract val isConnected: Boolean diff --git a/app/src/androidMain/kotlin/de/westnordost/streetcomplete/screens/main/MainViewModelImpl.kt b/app/src/androidMain/kotlin/de/westnordost/streetcomplete/screens/main/MainViewModelImpl.kt index 3ff7bb028c0..c3262a22da9 100644 --- a/app/src/androidMain/kotlin/de/westnordost/streetcomplete/screens/main/MainViewModelImpl.kt +++ b/app/src/androidMain/kotlin/de/westnordost/streetcomplete/screens/main/MainViewModelImpl.kt @@ -16,6 +16,7 @@ import de.westnordost.streetcomplete.data.osm.edits.ElementEditsSource import de.westnordost.streetcomplete.data.osm.edits.update_tags.PendingTagConflict import de.westnordost.streetcomplete.data.osm.edits.update_tags.PendingTagConflictsController import de.westnordost.streetcomplete.data.osm.mapdata.BoundingBox +import de.westnordost.streetcomplete.data.osm.mapdata.ElementType import de.westnordost.streetcomplete.data.osm.mapdata.LatLon import de.westnordost.streetcomplete.data.osmnotes.edits.NoteEdit import de.westnordost.streetcomplete.data.osmnotes.edits.NoteEditsSource @@ -354,6 +355,9 @@ class MainViewModelImpl( discardedEditNoticesController.dismiss(notice) } + override suspend fun getElementLabel(type: ElementType, id: Long): String? = + "${type.name.lowercase().replaceFirstChar { it.uppercase() }} #$id" + private val elementEditsListener = object : ElementEditsSource.Listener { override fun onAddedEdit(edit: ElementEdit) { launch { ensureLoggedIn() } } override fun onSyncedEdit(edit: ElementEdit) {} diff --git a/app/src/androidMain/kotlin/de/westnordost/streetcomplete/screens/main/conflicts/DiscardedEditNoticeEffect.kt b/app/src/androidMain/kotlin/de/westnordost/streetcomplete/screens/main/conflicts/DiscardedEditNoticeEffect.kt index 95ad4e38cec..5e6a44119a0 100644 --- a/app/src/androidMain/kotlin/de/westnordost/streetcomplete/screens/main/conflicts/DiscardedEditNoticeEffect.kt +++ b/app/src/androidMain/kotlin/de/westnordost/streetcomplete/screens/main/conflicts/DiscardedEditNoticeEffect.kt @@ -11,6 +11,7 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import de.westnordost.streetcomplete.data.osm.edits.DiscardedEditNotice +import de.westnordost.streetcomplete.data.osm.mapdata.ElementType import kotlinx.coroutines.launch /** @@ -28,14 +29,20 @@ fun DiscardedEditNoticeEffect( discardedNoticesCount: Int, onPopNextDiscardedNotice: suspend () -> DiscardedEditNotice?, onDismissDiscardedNotice: suspend (DiscardedEditNotice) -> Unit, + onGetElementLabel: suspend (ElementType, Long) -> String?, ) { val scope = rememberCoroutineScope() var current by remember { mutableStateOf(null) } + var elementLabel by remember { mutableStateOf(null) } var isDismissing by remember { mutableStateOf(false) } LaunchedEffect(discardedNoticesCount, current) { if (current == null && discardedNoticesCount > 0) { - current = onPopNextDiscardedNotice() + val notice = onPopNextDiscardedNotice() + elementLabel = if (notice?.elementType != null && notice.elementId != null) { + onGetElementLabel(notice.elementType, notice.elementId) + } else null + current = notice } } @@ -55,8 +62,9 @@ fun DiscardedEditNoticeEffect( onDismissRequest = ::dismiss, title = { Text("Answer could not be saved") }, text = { + val where = elementLabel ?: "near here" Text( - "Your answer for ${notice.editType.name} near here could not be saved: " + + "Your answer for ${notice.editType.name} on $where could not be saved: " + "${notice.reason}. The quest may show up again." ) }, diff --git a/app/src/androidMain/kotlin/de/westnordost/streetcomplete/screens/main/conflicts/TagConflictResolutionEffect.kt b/app/src/androidMain/kotlin/de/westnordost/streetcomplete/screens/main/conflicts/TagConflictResolutionEffect.kt index 4144ed6c392..1a8a6e225df 100644 --- a/app/src/androidMain/kotlin/de/westnordost/streetcomplete/screens/main/conflicts/TagConflictResolutionEffect.kt +++ b/app/src/androidMain/kotlin/de/westnordost/streetcomplete/screens/main/conflicts/TagConflictResolutionEffect.kt @@ -20,6 +20,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import androidx.compose.ui.window.DialogProperties import de.westnordost.streetcomplete.data.osm.edits.update_tags.PendingTagConflict +import de.westnordost.streetcomplete.data.osm.mapdata.ElementType import kotlinx.coroutines.launch /** @@ -41,9 +42,11 @@ fun TagConflictResolutionEffect( onPopNextConflictGroup: suspend () -> List, onResolveKeepMine: suspend (PendingTagConflict) -> Unit, onResolveKeepTheirs: suspend (PendingTagConflict) -> Unit, + onGetElementLabel: suspend (ElementType, Long) -> String?, ) { val scope = rememberCoroutineScope() var currentGroup by remember { mutableStateOf>(emptyList()) } + var elementLabel by remember { mutableStateOf(null) } var isApplying by remember { mutableStateOf(false) } val keepMine = remember { mutableStateMapOf() } @@ -54,6 +57,7 @@ fun TagConflictResolutionEffect( // default to keeping the user's own answer - they answered these, assume they still // want them unless they uncheck one group.forEach { keepMine[it.id] = true } + elementLabel = group.firstOrNull()?.let { onGetElementLabel(it.elementType, it.elementId) } currentGroup = group } } @@ -81,9 +85,10 @@ fun TagConflictResolutionEffect( title = { Text("Someone else edited this too") }, text = { Column { + val where = elementLabel ?: "${group.first().elementType.name.lowercase()} #${group.first().elementId}" Text( - "While you were answering, someone else changed the following on the same " + - "${group.first().elementType.name.lowercase()}. Keep your answer for each?" + "While you were answering, someone else changed the following on $where. " + + "Keep your answer for each?" ) group.forEach { conflict -> val mine = conflict.mineValue ?: "(removed)" diff --git a/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/osm/edits/DiscardedEditNotice.kt b/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/osm/edits/DiscardedEditNotice.kt index 5e539d8e9c5..2dac574250e 100644 --- a/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/osm/edits/DiscardedEditNotice.kt +++ b/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/osm/edits/DiscardedEditNotice.kt @@ -1,5 +1,6 @@ package de.westnordost.streetcomplete.data.osm.edits +import de.westnordost.streetcomplete.data.osm.mapdata.ElementType import de.westnordost.streetcomplete.data.osm.mapdata.LatLon /** A local edit that could not be applied because the underlying element was deleted or changed @@ -10,6 +11,10 @@ import de.westnordost.streetcomplete.data.osm.mapdata.LatLon data class DiscardedEditNotice( var id: Long, val editType: ElementEditType, + /** null if the action this edit was for didn't refer to any existing element (e.g. creating a + * new node) - there's nothing to identify in that case */ + val elementType: ElementType?, + val elementId: Long?, val position: LatLon, val reason: String, val createdTimestamp: Long, diff --git a/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/osm/edits/DiscardedEditNoticesDao.kt b/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/osm/edits/DiscardedEditNoticesDao.kt index 09648fe7e22..b11e998669c 100644 --- a/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/osm/edits/DiscardedEditNoticesDao.kt +++ b/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/osm/edits/DiscardedEditNoticesDao.kt @@ -4,6 +4,8 @@ import de.westnordost.streetcomplete.data.AllEditTypes import de.westnordost.streetcomplete.data.CursorPosition import de.westnordost.streetcomplete.data.Database import de.westnordost.streetcomplete.data.osm.edits.DiscardedEditNoticesTable.Columns.CREATED_TIMESTAMP +import de.westnordost.streetcomplete.data.osm.edits.DiscardedEditNoticesTable.Columns.ELEMENT_ID +import de.westnordost.streetcomplete.data.osm.edits.DiscardedEditNoticesTable.Columns.ELEMENT_TYPE import de.westnordost.streetcomplete.data.osm.edits.DiscardedEditNoticesTable.Columns.ID import de.westnordost.streetcomplete.data.osm.edits.DiscardedEditNoticesTable.Columns.LATITUDE import de.westnordost.streetcomplete.data.osm.edits.DiscardedEditNoticesTable.Columns.LONGITUDE @@ -11,6 +13,7 @@ import de.westnordost.streetcomplete.data.osm.edits.DiscardedEditNoticesTable.Co import de.westnordost.streetcomplete.data.osm.edits.DiscardedEditNoticesTable.Columns.REASON import de.westnordost.streetcomplete.data.osm.edits.DiscardedEditNoticesTable.Columns.WORKSPACE_ID import de.westnordost.streetcomplete.data.osm.edits.DiscardedEditNoticesTable.NAME +import de.westnordost.streetcomplete.data.osm.mapdata.ElementType import de.westnordost.streetcomplete.data.osm.mapdata.LatLon import de.westnordost.streetcomplete.data.preferences.Preferences @@ -46,6 +49,8 @@ class DiscardedEditNoticesDao( private fun DiscardedEditNotice.toPairs(): List> = listOf( QUEST_TYPE to editType.name, + ELEMENT_TYPE to elementType?.name, + ELEMENT_ID to elementId, REASON to reason, LATITUDE to position.latitude, LONGITUDE to position.longitude, @@ -56,6 +61,8 @@ class DiscardedEditNoticesDao( private fun CursorPosition.toDiscardedEditNotice() = DiscardedEditNotice( id = getLong(ID), editType = allEditTypes.getByName(getString(QUEST_TYPE)) as ElementEditType, + elementType = getStringOrNull(ELEMENT_TYPE)?.let { ElementType.valueOf(it) }, + elementId = getLongOrNull(ELEMENT_ID), reason = getString(REASON), position = LatLon(getDouble(LATITUDE), getDouble(LONGITUDE)), createdTimestamp = getLong(CREATED_TIMESTAMP), diff --git a/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/osm/edits/DiscardedEditNoticesTable.kt b/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/osm/edits/DiscardedEditNoticesTable.kt index c9636faaf7a..325f244b81b 100644 --- a/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/osm/edits/DiscardedEditNoticesTable.kt +++ b/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/osm/edits/DiscardedEditNoticesTable.kt @@ -6,6 +6,8 @@ object DiscardedEditNoticesTable { object Columns { const val ID = "id" const val QUEST_TYPE = "quest_type" + const val ELEMENT_TYPE = "element_type" + const val ELEMENT_ID = "element_id" const val REASON = "reason" const val LATITUDE = "latitude" const val LONGITUDE = "longitude" @@ -17,6 +19,8 @@ object DiscardedEditNoticesTable { CREATE TABLE $NAME ( ${Columns.ID} INTEGER PRIMARY KEY AUTOINCREMENT, ${Columns.QUEST_TYPE} varchar(255) NOT NULL, + ${Columns.ELEMENT_TYPE} varchar(255), + ${Columns.ELEMENT_ID} int, ${Columns.REASON} varchar(255) NOT NULL, ${Columns.LATITUDE} double NOT NULL, ${Columns.LONGITUDE} double NOT NULL, diff --git a/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/osm/edits/upload/ElementEditsUploader.kt b/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/osm/edits/upload/ElementEditsUploader.kt index 733dd754c64..5f751f7af4b 100644 --- a/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/osm/edits/upload/ElementEditsUploader.kt +++ b/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/osm/edits/upload/ElementEditsUploader.kt @@ -77,10 +77,13 @@ class ElementEditsUploader( uploadedChangeListener?.onDiscarded(edit.type.name, edit.position) elementEditsController.markSyncFailed(edit) + val elementKey = edit.action.elementKeys.firstOrNull() discardedEditNoticesController.add( DiscardedEditNotice( id = 0, editType = edit.type, + elementType = elementKey?.type, + elementId = elementKey?.id, position = edit.position, reason = e.message ?: "Could not be applied to the current state of the map", createdTimestamp = nowAsEpochMilliseconds() From 5f6b261746dcf72063011f0728f00530af50b7b3 Mon Sep 17 00:00:00 2001 From: Rajesh Kantipudi <44539669+iamrajeshk@users.noreply.github.com> Date: Tue, 7 Jul 2026 16:55:00 +0530 Subject: [PATCH 14/19] Update database schema to version 26; add element_type and element_id columns to DiscardedEditNoticesTable and adjust UI message formatting for clarity --- .../main/conflicts/DiscardedEditNoticeEffect.kt | 4 ++-- .../streetcomplete/data/DatabaseInitializer.kt | 10 +++++++++- .../data/osm/edits/upload/ElementEditsUploader.kt | 3 ++- 3 files changed, 13 insertions(+), 4 deletions(-) diff --git a/app/src/androidMain/kotlin/de/westnordost/streetcomplete/screens/main/conflicts/DiscardedEditNoticeEffect.kt b/app/src/androidMain/kotlin/de/westnordost/streetcomplete/screens/main/conflicts/DiscardedEditNoticeEffect.kt index 5e6a44119a0..089b014d67c 100644 --- a/app/src/androidMain/kotlin/de/westnordost/streetcomplete/screens/main/conflicts/DiscardedEditNoticeEffect.kt +++ b/app/src/androidMain/kotlin/de/westnordost/streetcomplete/screens/main/conflicts/DiscardedEditNoticeEffect.kt @@ -64,8 +64,8 @@ fun DiscardedEditNoticeEffect( text = { val where = elementLabel ?: "near here" Text( - "Your answer for ${notice.editType.name} on $where could not be saved: " + - "${notice.reason}. The quest may show up again." + "Your answer for ${notice.editType.name} on $where could not be saved \n Reason: " + + "${notice.reason}." ) }, confirmButton = { diff --git a/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/DatabaseInitializer.kt b/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/DatabaseInitializer.kt index dc5513ed5a7..21181b7bc31 100644 --- a/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/DatabaseInitializer.kt +++ b/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/DatabaseInitializer.kt @@ -32,7 +32,7 @@ import de.westnordost.streetcomplete.util.logs.Log /** Creates the database and upgrades it */ object DatabaseInitializer { - const val DB_VERSION = 25 + const val DB_VERSION = 26 fun onCreate(db: Database) { // OSM notes @@ -321,6 +321,14 @@ object DatabaseInitializer { if (oldVersion <= 24 && newVersion >= 25) { db.exec(DiscardedEditNoticesTable.CREATE) } + + if (oldVersion <= 25 && newVersion >= 26) { + // element_type/element_id were added to DiscardedEditNoticesTable.CREATE after some + // devices had already created the table at v25 in its original shape. tryExec because + // installs that first created the table at v25 *with* the columns would fail the ALTER + db.tryExec("ALTER TABLE ${DiscardedEditNoticesTable.NAME} ADD COLUMN ${DiscardedEditNoticesTable.Columns.ELEMENT_TYPE} varchar(255)") + db.tryExec("ALTER TABLE ${DiscardedEditNoticesTable.NAME} ADD COLUMN ${DiscardedEditNoticesTable.Columns.ELEMENT_ID} int") + } } } diff --git a/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/osm/edits/upload/ElementEditsUploader.kt b/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/osm/edits/upload/ElementEditsUploader.kt index 5f751f7af4b..af83d07db38 100644 --- a/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/osm/edits/upload/ElementEditsUploader.kt +++ b/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/osm/edits/upload/ElementEditsUploader.kt @@ -86,7 +86,8 @@ class ElementEditsUploader( elementId = elementKey?.id, position = edit.position, reason = e.message ?: "Could not be applied to the current state of the map", - createdTimestamp = nowAsEpochMilliseconds() + createdTimestamp = nowAsEpochMilliseconds(), + workspaceId = edit.workspaceId ) ) From e9d48a4d6c5fa11ce0bf0a32f14b214bb2c92ca3 Mon Sep 17 00:00:00 2001 From: Rajesh Kantipudi <44539669+iamrajeshk@users.noreply.github.com> Date: Tue, 7 Jul 2026 17:41:41 +0530 Subject: [PATCH 15/19] Add conflict review mechanism to handle user-initiated conflict resolution; update UI to display conflicts in a bottom sheet and track review requests --- .../screens/main/MainActivity.kt | 3 + .../streetcomplete/screens/main/MainScreen.kt | 5 + .../screens/main/MainViewModel.kt | 4 + .../screens/main/MainViewModelImpl.kt | 6 + .../conflicts/TagConflictResolutionEffect.kt | 275 +++++++++++++++--- 5 files changed, 248 insertions(+), 45 deletions(-) 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 95c19a8a392..eed1f7f9592 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 @@ -1212,6 +1212,9 @@ class MainActivity : repeatOnLifecycle(Lifecycle.State.STARTED) { viewModel.unsyncedEditsCount.collect { count -> uploadButton.setOnClickListener { + // an explicit tap also re-opens the conflict sheet if the user + // postponed it with Cancel earlier + viewModel.requestConflictReview() if (count > 0) { if (viewModel.isConnected) { viewModel.upload() diff --git a/app/src/androidMain/kotlin/de/westnordost/streetcomplete/screens/main/MainScreen.kt b/app/src/androidMain/kotlin/de/westnordost/streetcomplete/screens/main/MainScreen.kt index af2df9718ed..c46e57d43ef 100644 --- a/app/src/androidMain/kotlin/de/westnordost/streetcomplete/screens/main/MainScreen.kt +++ b/app/src/androidMain/kotlin/de/westnordost/streetcomplete/screens/main/MainScreen.kt @@ -165,6 +165,7 @@ fun MainScreen( val showZoomButtons by viewModel.showZoomButtons.collectAsState() val pendingConflictsCount by viewModel.pendingConflictsCount.collectAsState() + val conflictReviewRequests by viewModel.conflictReviewRequests.collectAsState() val discardedNoticesCount by viewModel.discardedNoticesCount.collectAsState() var showOverlaysDropdown by remember { mutableStateOf(false) } @@ -514,6 +515,7 @@ fun MainScreen( } TagConflictResolutionEffect( pendingConflictsCount = pendingConflictsCount, + reviewRequests = conflictReviewRequests, onPopNextConflictGroup = { viewModel.popNextConflictGroup() }, onResolveKeepMine = { viewModel.resolveConflictKeepMine(it) }, onResolveKeepTheirs = { viewModel.resolveConflictKeepTheirs(it) }, @@ -690,6 +692,9 @@ object PreviewMainViewModel : MainViewModel() { get() = MutableStateFlow(true) override val pendingConflictsCount: StateFlow get() = MutableStateFlow(0) + override val conflictReviewRequests: StateFlow + get() = MutableStateFlow(0) + override fun requestConflictReview() {} override suspend fun popNextConflictGroup(): List = emptyList() override suspend fun resolveConflictKeepMine(conflict: PendingTagConflict) {} override suspend fun resolveConflictKeepTheirs(conflict: PendingTagConflict) {} diff --git a/app/src/androidMain/kotlin/de/westnordost/streetcomplete/screens/main/MainViewModel.kt b/app/src/androidMain/kotlin/de/westnordost/streetcomplete/screens/main/MainViewModel.kt index c2f5a324fb3..e0ccfe7a177 100644 --- a/app/src/androidMain/kotlin/de/westnordost/streetcomplete/screens/main/MainViewModel.kt +++ b/app/src/androidMain/kotlin/de/westnordost/streetcomplete/screens/main/MainViewModel.kt @@ -63,6 +63,10 @@ abstract class MainViewModel : ViewModel() { /* tag conflicts held back instead of being discarded, to be resolved one at a time */ abstract val pendingConflictsCount: StateFlow + /** Bumped each time the user explicitly asks to review pending conflicts (tapping the toolbar + * upload button), so a conflict sheet postponed via Cancel re-opens without an app restart */ + abstract val conflictReviewRequests: StateFlow + abstract fun requestConflictReview() abstract suspend fun popNextConflictGroup(): List abstract suspend fun resolveConflictKeepMine(conflict: PendingTagConflict) abstract suspend fun resolveConflictKeepTheirs(conflict: PendingTagConflict) diff --git a/app/src/androidMain/kotlin/de/westnordost/streetcomplete/screens/main/MainViewModelImpl.kt b/app/src/androidMain/kotlin/de/westnordost/streetcomplete/screens/main/MainViewModelImpl.kt index c3262a22da9..faee29c5cb4 100644 --- a/app/src/androidMain/kotlin/de/westnordost/streetcomplete/screens/main/MainViewModelImpl.kt +++ b/app/src/androidMain/kotlin/de/westnordost/streetcomplete/screens/main/MainViewModelImpl.kt @@ -313,6 +313,12 @@ class MainViewModelImpl( awaitClose { pendingTagConflictsController.removeListener(listener) } }.stateIn(viewModelScope + IO, SharingStarted.Eagerly, 0) + override val conflictReviewRequests = MutableStateFlow(0) + + override fun requestConflictReview() { + conflictReviewRequests.value++ + } + override suspend fun popNextConflictGroup(): List = withContext(IO) { pendingTagConflictsController.getOldestGroup() } diff --git a/app/src/androidMain/kotlin/de/westnordost/streetcomplete/screens/main/conflicts/TagConflictResolutionEffect.kt b/app/src/androidMain/kotlin/de/westnordost/streetcomplete/screens/main/conflicts/TagConflictResolutionEffect.kt index 1a8a6e225df..587b97af3d9 100644 --- a/app/src/androidMain/kotlin/de/westnordost/streetcomplete/screens/main/conflicts/TagConflictResolutionEffect.kt +++ b/app/src/androidMain/kotlin/de/westnordost/streetcomplete/screens/main/conflicts/TagConflictResolutionEffect.kt @@ -1,12 +1,31 @@ package de.westnordost.streetcomplete.screens.main.conflicts +import android.util.Log +import androidx.compose.foundation.Image +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding -import androidx.compose.material3.AlertDialog -import androidx.compose.material3.Checkbox +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Check +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.Text import androidx.compose.material3.TextButton +import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -17,28 +36,44 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp -import androidx.compose.ui.window.DialogProperties import de.westnordost.streetcomplete.data.osm.edits.update_tags.PendingTagConflict import de.westnordost.streetcomplete.data.osm.mapdata.ElementType +import de.westnordost.streetcomplete.quests.sidewalk_long_form.AddGenericLong import kotlinx.coroutines.launch /** - * Shows all pending tag conflicts *for one element* together, in a single dialog, instead of one - * dialog per conflicting tag: while answering a quest, a concurrent remote edit changed some of - * the same OSM tags on the same element. Rather than silently discarding the whole answer (the - * old behavior), the non-conflicting part already went through - this only asks about the - * specific tag(s) that collided, and lets the user decide per tag whether to keep their own answer - * (checked, the default) or accept the other edit's value (unchecked). + * Shows all pending tag conflicts *for one element* together, in a single "Resolve Conflicts" + * bottom sheet: while answering a quest, a concurrent remote edit changed some of the same OSM + * tags on the same element. Rather than silently discarding the whole answer (the old behavior), + * the non-conflicting part already went through - this only asks about the specific tag(s) that + * collided. Each conflict is shown as the long-form question it came from (falling back to the + * raw tag key for non-long-form edits) with two tappable rows - the user's own answer (selected + * by default) and the value someone else set - and a single Confirm applying the per-row choices. + * + * Cancel (or swiping the sheet away) postpones instead of resolving: nothing is uploaded or + * dropped, the conflicts stay pending (and counted in the toolbar badge), and the sheet comes + * back when the pending count changes (new conflict, sync), when the user taps the toolbar + * upload button ([reviewRequests] bumps), or on app restart. + * + * Confirm dismisses the sheet immediately and resolves the choices in the background - progress + * is visible through the toolbar spinner/badge, and if resolution fails partway (e.g. network + * dropped), the unresolved conflicts simply stay pending and the sheet comes back on the next + * trigger. * * Driven by [pendingConflictsCount] rather than a one-shot event so it naturally re-triggers * (fetching the next element's group of conflicts) whenever the current one is applied, giving a - * strictly one-dialog-at-a-time queue even if several elements' conflicts piled up while the app + * strictly one-sheet-at-a-time queue even if several elements' conflicts piled up while the app * was backgrounded. */ +@OptIn(ExperimentalMaterial3Api::class) @Composable fun TagConflictResolutionEffect( pendingConflictsCount: Int, + reviewRequests: Int, onPopNextConflictGroup: suspend () -> List, onResolveKeepMine: suspend (PendingTagConflict) -> Unit, onResolveKeepTheirs: suspend (PendingTagConflict) -> Unit, @@ -48,14 +83,27 @@ fun TagConflictResolutionEffect( var currentGroup by remember { mutableStateOf>(emptyList()) } var elementLabel by remember { mutableStateOf(null) } var isApplying by remember { mutableStateOf(false) } + // the count at which the user hit Cancel - suppresses re-fetching until the count changes + // (i.e. something new happened), the user taps the upload button, or the app restarts + var postponedAtCount by remember { mutableStateOf(null) } + var lastReviewRequests by remember { mutableStateOf(reviewRequests) } val keepMine = remember { mutableStateMapOf() } - LaunchedEffect(pendingConflictsCount, currentGroup) { - if (currentGroup.isEmpty() && pendingConflictsCount > 0) { + LaunchedEffect(pendingConflictsCount, currentGroup, reviewRequests, isApplying) { + if (reviewRequests != lastReviewRequests) { + lastReviewRequests = reviewRequests + postponedAtCount = null + } + if (postponedAtCount != null && postponedAtCount != pendingConflictsCount) { + postponedAtCount = null + } + // !isApplying: the rows being resolved in the background are still in the DB until each + // one completes - fetching now would just pop the same group again + if (currentGroup.isEmpty() && pendingConflictsCount > 0 && postponedAtCount == null && !isApplying) { val group = onPopNextConflictGroup() keepMine.clear() // default to keeping the user's own answer - they answered these, assume they still - // want them unless they uncheck one + // want them unless they pick the existing value group.forEach { keepMine[it.id] = true } elementLabel = group.firstOrNull()?.let { onGetElementLabel(it.elementType, it.elementId) } currentGroup = group @@ -65,52 +113,189 @@ fun TagConflictResolutionEffect( val group = currentGroup if (group.isEmpty()) return + fun postpone() { + postponedAtCount = pendingConflictsCount + currentGroup = emptyList() + } + fun apply() { if (isApplying) return isApplying = true + val choices = group.map { it to (keepMine[it.id] ?: true) } + // dismiss right away - resolution continues in the background, surfaced via the toolbar + // spinner, and rows only leave the DB/badge as they actually resolve + currentGroup = emptyList() scope.launch { - for (conflict in group) { - if (keepMine[conflict.id] == true) onResolveKeepMine(conflict) else onResolveKeepTheirs(conflict) + try { + for ((conflict, keep) in choices) { + if (keep) onResolveKeepMine(conflict) else onResolveKeepTheirs(conflict) + } + } catch (e: Exception) { + Log.w( + "TagConflictResolution", + "Resolving conflicts failed, unresolved ones stay pending", e + ) + } finally { + isApplying = false } - isApplying = false - currentGroup = emptyList() } } - AlertDialog( - // must make an explicit choice - this is not just informational, it decides what ends up - // on the server - onDismissRequest = {}, - properties = DialogProperties(dismissOnBackPress = false, dismissOnClickOutside = false), - title = { Text("Someone else edited this too") }, - text = { - Column { - val where = elementLabel ?: "${group.first().elementType.name.lowercase()} #${group.first().elementId}" + val editType = group.first().editType + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + + ModalBottomSheet( + onDismissRequest = ::postpone, + sheetState = sheetState, + shape = RoundedCornerShape(topStart = 24.dp, topEnd = 24.dp), + containerColor = MaterialTheme.colorScheme.surface, + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .verticalScroll(rememberScrollState()) + .padding(start = 20.dp, end = 20.dp, bottom = 24.dp) + ) { + Box(modifier = Modifier.fillMaxWidth()) { + TextButton( + onClick = ::postpone, + modifier = Modifier.align(Alignment.CenterStart) + ) { + Text("Cancel") + } Text( - "While you were answering, someone else changed the following on $where. " + - "Keep your answer for each?" + "Resolve Conflicts", + style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold), + modifier = Modifier.align(Alignment.Center) ) - group.forEach { conflict -> - val mine = conflict.mineValue ?: "(removed)" - val theirs = conflict.theirsValueAtDetection ?: "(removed)" - Row( - modifier = Modifier.padding(top = 12.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Checkbox( - enabled = !isApplying, - checked = keepMine[conflict.id] ?: true, - onCheckedChange = { keepMine[conflict.id] = it } + TextButton( + onClick = ::apply, + modifier = Modifier.align(Alignment.CenterEnd) + ) { + Text("Confirm", fontWeight = FontWeight.Bold) + } + } + + Spacer(Modifier.height(12.dp)) + + Card( + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.4f) + ), + modifier = Modifier.fillMaxWidth() + ) { + Column(modifier = Modifier.padding(16.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Image( + painter = painterResource(editType.icon), + contentDescription = null, + modifier = Modifier.size(40.dp) ) - Text("${conflict.tagKey}: you said “$mine”, they set “$theirs”") + Column(modifier = Modifier.padding(start = 12.dp)) { + // same category name the long-form quest title shows + // ("Sidewalks — Way #123"), so the user can relate this sheet back + // to the quest they answered; editType.title would be the question + // sentence ("Does this street have a sidewalk?") instead + Text( + (editType as? AddGenericLong)?.item?.elementType + ?: stringResource(editType.title), + style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold) + ) + elementLabel?.let { + Text( + it, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } } + HorizontalDivider(modifier = Modifier.padding(vertical = 12.dp)) + Text( + "This element was changed by someone else while you were answering. " + + "Choose which value to keep for each question below.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) } } - }, - confirmButton = { - TextButton(enabled = !isApplying, onClick = ::apply) { - Text("Apply") + + group.forEach { conflict -> + Spacer(Modifier.height(20.dp)) + + // the long-form question this tag came from reads much better than the raw OSM + // key; the key is still shown underneath so the value can be traced in OSM data + val question = questionTitleFor(conflict) + Text( + question ?: conflict.tagKey.uppercase(), + style = MaterialTheme.typography.titleSmall, + modifier = Modifier.padding(horizontal = 4.dp) + ) + if (question != null) { + Text( + conflict.tagKey.uppercase(), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 4.dp, vertical = 2.dp) + ) + } + Spacer(Modifier.height(6.dp)) + + val mine = keepMine[conflict.id] ?: true + Card( + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.4f) + ), + modifier = Modifier.fillMaxWidth() + ) { + ConflictChoiceRow( + text = "Your answer: ${conflict.mineValue ?: "(removed)"}", + selected = mine, + onClick = { keepMine[conflict.id] = true } + ) + HorizontalDivider(modifier = Modifier.padding(start = 16.dp)) + ConflictChoiceRow( + text = "Existing value: ${conflict.theirsValueAtDetection ?: "(removed)"}", + selected = !mine, + onClick = { keepMine[conflict.id] = false } + ) + } } } - ) + } +} + +@Composable +private fun ConflictChoiceRow( + text: String, + selected: Boolean, + onClick: () -> Unit, +) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding(horizontal = 16.dp, vertical = 14.dp) + ) { + Text( + text, + style = MaterialTheme.typography.bodyLarge, + modifier = Modifier.weight(1f) + ) + if (selected) { + Icon( + Icons.Default.Check, + contentDescription = "Selected", + tint = MaterialTheme.colorScheme.primary + ) + } + } } + +/** The long-form question text the conflicting tag was answered through, if this conflict came + * from a long-form quest ([AddGenericLong] carries its workspace-defined question catalog) */ +private fun questionTitleFor(conflict: PendingTagConflict): String? = + (conflict.editType as? AddGenericLong)?.item?.quests + ?.firstOrNull { it?.questTag == conflict.tagKey } + ?.questTitle From a5baf4582275885164fda120b06f07a28fea891b Mon Sep 17 00:00:00 2001 From: Rajesh Kantipudi <44539669+iamrajeshk@users.noreply.github.com> Date: Wed, 8 Jul 2026 13:27:31 +0530 Subject: [PATCH 16/19] Refactor follow-up handling in LongFormAdapter to ensure visibility and text updates persist across rebinds; update Preferences with new access token --- .../data/LongFormAdapter.kt | 30 +++++++++++++------ .../data/preferences/Preferences.kt | 2 +- 2 files changed, 22 insertions(+), 10 deletions(-) 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 16b13d1fcb3..e894dba34b3 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 @@ -401,6 +401,9 @@ class LongFormAdapter(val cameraIntent: () -> Unit) : binding.choiceFollowUp.setOnClickListener { cameraIntent() } + // apply from current state, not just on selection events - the follow-up must also + // survive rebinds and holders recycled from other questions + updateChoiceFollowUp(item) imageSelectAdapter.selectedIndices = item.selectedIndex ?: emptyList() @@ -428,15 +431,10 @@ class LongFormAdapter(val cameraIntent: () -> Unit) : } fun handleChoiceFollowUp() { - item.selectedIndex?.forEach { index -> - if (!item.questAnswerChoices?.get(index)?.choiceFollowUp.isNullOrBlank()) { - binding.choiceFollowUp.visibility = View.VISIBLE - binding.choiceFollowUp.text = - item.questAnswerChoices[index]?.choiceFollowUp - return - } - } - binding.choiceFollowUp.visibility = View.GONE + // item is a snapshot taken at bind time - the selection that was just made + // lives in givenItems, so read the follow-up state from there + val live = givenItems.firstOrNull { it.questId == item.questId } ?: item + updateChoiceFollowUp(live) } override fun onLongPress(index: Int, drawable: Drawable?) { @@ -503,6 +501,20 @@ class LongFormAdapter(val cameraIntent: () -> Unit) : } } + /** Show the follow-up prompt (e.g. "Please take a photo of the obstruction.") of the + * first selected choice that has one, hide it if none of the selected choices do */ + private fun updateChoiceFollowUp(quest: LongFormQuest) { + quest.selectedIndex?.forEach { index -> + val followUp = quest.questAnswerChoices?.get(index)?.choiceFollowUp + if (!followUp.isNullOrBlank()) { + binding.choiceFollowUp.visibility = View.VISIBLE + binding.choiceFollowUp.text = followUp + return + } + } + binding.choiceFollowUp.visibility = View.GONE + } + fun handleDeselection( questId: Int, userInput: String, 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 d2228f9e1f3..8f48ecb427b 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 @@ -446,6 +446,6 @@ class Preferences(private val prefs: ObservableSettings) { 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" + "5b7bfc9104ad198bdba4108ed3048f380f118cfb528d750a2bf31041b7266a99" } } From 8ce3aeab3659b17835d405c368a2892d6c808878 Mon Sep 17 00:00:00 2001 From: Rajesh Kantipudi <44539669+iamrajeshk@users.noreply.github.com> Date: Thu, 9 Jul 2026 14:23:35 +0530 Subject: [PATCH 17/19] Implement conflict blocking mechanism for unsynced edits; update database schema and UI to handle tag conflicts --- .../screens/main/MainActivity.kt | 12 +- .../streetcomplete/screens/main/MainScreen.kt | 4 +- .../screens/main/MainViewModelImpl.kt | 27 +-- .../conflicts/TagConflictResolutionEffect.kt | 42 ++-- .../data/DatabaseInitializer.kt | 16 +- .../data/osm/edits/ElementEdit.kt | 8 +- .../data/osm/edits/ElementEditsController.kt | 18 ++ .../data/osm/edits/ElementEditsDao.kt | 11 +- .../data/osm/edits/ElementEditsModule.kt | 2 +- .../data/osm/edits/ElementEditsTable.kt | 6 +- .../edits/update_tags/PendingTagConflict.kt | 8 +- .../PendingTagConflictsController.kt | 180 ++++++++---------- .../update_tags/PendingTagConflictsDao.kt | 3 + .../update_tags/PendingTagConflictsTable.kt | 2 + .../osm/edits/upload/ElementEditUploader.kt | 103 +++++----- .../osm/edits/upload/ElementEditsUploader.kt | 7 + 16 files changed, 226 insertions(+), 223 deletions(-) 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 eed1f7f9592..1c6a872014d 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 @@ -1193,15 +1193,15 @@ class MainActivity : lifecycleScope.launch { repeatOnLifecycle(Lifecycle.State.STARTED) { - // badge shows unsynced edits, tag conflicts still awaiting the user's decision, - // and notices about discarded edits still to be acknowledged - everything that - // still needs attention + // badge shows unsynced edits (which already includes edits blocked on + // unresolved tag conflicts) and notices about discarded edits still to be + // acknowledged - everything that still needs attention. Conflicts are not + // added separately, that would double-count their blocked edit combine( viewModel.unsyncedEditsCount, - viewModel.pendingConflictsCount, viewModel.discardedNoticesCount - ) { edits, conflicts, discarded -> - edits + conflicts + discarded + ) { edits, discarded -> + edits + discarded }.collect { totalPendingCount -> uploadButton.uploadableCount = totalPendingCount } diff --git a/app/src/androidMain/kotlin/de/westnordost/streetcomplete/screens/main/MainScreen.kt b/app/src/androidMain/kotlin/de/westnordost/streetcomplete/screens/main/MainScreen.kt index c46e57d43ef..7e30bef50dd 100644 --- a/app/src/androidMain/kotlin/de/westnordost/streetcomplete/screens/main/MainScreen.kt +++ b/app/src/androidMain/kotlin/de/westnordost/streetcomplete/screens/main/MainScreen.kt @@ -519,7 +519,9 @@ fun MainScreen( onPopNextConflictGroup = { viewModel.popNextConflictGroup() }, onResolveKeepMine = { viewModel.resolveConflictKeepMine(it) }, onResolveKeepTheirs = { viewModel.resolveConflictKeepTheirs(it) }, - onGetElementLabel = { type, id -> viewModel.getElementLabel(type, id) } + onGetElementLabel = { type, id -> viewModel.getElementLabel(type, id) }, + // resolving unblocked the held edit - upload it right away + onResolutionFinished = { viewModel.upload() } ) DiscardedEditNoticeEffect( discardedNoticesCount = discardedNoticesCount, diff --git a/app/src/androidMain/kotlin/de/westnordost/streetcomplete/screens/main/MainViewModelImpl.kt b/app/src/androidMain/kotlin/de/westnordost/streetcomplete/screens/main/MainViewModelImpl.kt index faee29c5cb4..da25350a68c 100644 --- a/app/src/androidMain/kotlin/de/westnordost/streetcomplete/screens/main/MainViewModelImpl.kt +++ b/app/src/androidMain/kotlin/de/westnordost/streetcomplete/screens/main/MainViewModelImpl.kt @@ -269,13 +269,9 @@ class MainViewModelImpl( awaitClose { downloadProgressSource.removeListener(listener) } }.stateIn(viewModelScope, SharingStarted.Eagerly, downloadProgressSource.isDownloadInProgress) - // drives the same upload/download progress indicator (isUploadingOrDownloading below) while a - // conflict resolution is in flight, instead of adding a separate spinner for it - private val isResolvingConflict = MutableStateFlow(false) - override val isUploadingOrDownloading: StateFlow = - combine(isUploading, isDownloading, isResolvingConflict) { uploading, downloading, resolving -> - uploading || downloading || resolving + combine(isUploading, isDownloading) { uploading, downloading -> + uploading || downloading }.stateIn(viewModelScope, SharingStarted.Eagerly, false) override val isUserInitiatedDownloadInProgress: Boolean @@ -301,7 +297,7 @@ class MainViewModelImpl( } } - /* tag conflicts held back instead of being discarded */ + /* tag conflicts blocking their edit from uploading until the user resolves them */ override val pendingConflictsCount: StateFlow = callbackFlow { send(pendingTagConflictsController.getCount()) @@ -323,22 +319,15 @@ class MainViewModelImpl( pendingTagConflictsController.getOldestGroup() } + // resolving is a local DB operation - the actual upload of the unblocked edit happens through + // the normal sync path (triggered by the conflict sheet once the whole group is resolved) and + // is what drives the progress spinner override suspend fun resolveConflictKeepMine(conflict: PendingTagConflict) { - isResolvingConflict.value = true - try { - pendingTagConflictsController.resolveKeepMine(conflict) - } finally { - isResolvingConflict.value = false - } + pendingTagConflictsController.resolveKeepMine(conflict) } override suspend fun resolveConflictKeepTheirs(conflict: PendingTagConflict) { - isResolvingConflict.value = true - try { - pendingTagConflictsController.resolveKeepTheirs(conflict) - } finally { - isResolvingConflict.value = false - } + pendingTagConflictsController.resolveKeepTheirs(conflict) } /* notices about edits discarded due to an unsalvageable conflict */ diff --git a/app/src/androidMain/kotlin/de/westnordost/streetcomplete/screens/main/conflicts/TagConflictResolutionEffect.kt b/app/src/androidMain/kotlin/de/westnordost/streetcomplete/screens/main/conflicts/TagConflictResolutionEffect.kt index 587b97af3d9..7d1acff9eda 100644 --- a/app/src/androidMain/kotlin/de/westnordost/streetcomplete/screens/main/conflicts/TagConflictResolutionEffect.kt +++ b/app/src/androidMain/kotlin/de/westnordost/streetcomplete/screens/main/conflicts/TagConflictResolutionEffect.kt @@ -46,27 +46,26 @@ import de.westnordost.streetcomplete.quests.sidewalk_long_form.AddGenericLong import kotlinx.coroutines.launch /** - * Shows all pending tag conflicts *for one element* together, in a single "Resolve Conflicts" - * bottom sheet: while answering a quest, a concurrent remote edit changed some of the same OSM - * tags on the same element. Rather than silently discarding the whole answer (the old behavior), - * the non-conflicting part already went through - this only asks about the specific tag(s) that - * collided. Each conflict is shown as the long-form question it came from (falling back to the - * raw tag key for non-long-form edits) with two tappable rows - the user's own answer (selected - * by default) and the value someone else set - and a single Confirm applying the per-row choices. + * Shows all pending tag conflicts *of one held-back edit* together, in a single "Resolve + * Conflicts" bottom sheet: while answering a quest, a concurrent remote edit changed some of the + * same OSM tags on the same element. The whole answer is held back from uploading - nothing has + * been submitted yet. Each conflict is shown as the long-form question it came from (falling back + * to the raw tag key for non-long-form edits) with two tappable rows - the user's own answer + * (selected by default) and the value someone else set. Confirm folds the per-row choices into + * the held edit and it then uploads as one unit through the normal sync path. * - * Cancel (or swiping the sheet away) postpones instead of resolving: nothing is uploaded or - * dropped, the conflicts stay pending (and counted in the toolbar badge), and the sheet comes - * back when the pending count changes (new conflict, sync), when the user taps the toolbar - * upload button ([reviewRequests] bumps), or on app restart. + * Cancel (or swiping the sheet away) postpones instead of resolving: the answer stays held (and + * counted in the toolbar badge as an unsynced edit), and the sheet comes back when the pending + * count changes (new conflict, sync), when the user taps the toolbar upload button + * ([reviewRequests] bumps), or on app restart. * - * Confirm dismisses the sheet immediately and resolves the choices in the background - progress - * is visible through the toolbar spinner/badge, and if resolution fails partway (e.g. network - * dropped), the unresolved conflicts simply stay pending and the sheet comes back on the next - * trigger. + * Confirm dismisses the sheet immediately; the decisions are applied locally in the background + * and [onResolutionFinished] then triggers the upload, whose progress shows in the toolbar + * spinner as usual. * * Driven by [pendingConflictsCount] rather than a one-shot event so it naturally re-triggers - * (fetching the next element's group of conflicts) whenever the current one is applied, giving a - * strictly one-sheet-at-a-time queue even if several elements' conflicts piled up while the app + * (fetching the next edit's group of conflicts) whenever the current one is applied, giving a + * strictly one-sheet-at-a-time queue even if several edits' conflicts piled up while the app * was backgrounded. */ @OptIn(ExperimentalMaterial3Api::class) @@ -78,6 +77,7 @@ fun TagConflictResolutionEffect( onResolveKeepMine: suspend (PendingTagConflict) -> Unit, onResolveKeepTheirs: suspend (PendingTagConflict) -> Unit, onGetElementLabel: suspend (ElementType, Long) -> String?, + onResolutionFinished: () -> Unit, ) { val scope = rememberCoroutineScope() var currentGroup by remember { mutableStateOf>(emptyList()) } @@ -130,6 +130,9 @@ fun TagConflictResolutionEffect( for ((conflict, keep) in choices) { if (keep) onResolveKeepMine(conflict) else onResolveKeepTheirs(conflict) } + // the edit is unblocked now - push it right away instead of waiting for the + // next auto-sync + onResolutionFinished() } catch (e: Exception) { Log.w( "TagConflictResolution", @@ -212,8 +215,9 @@ fun TagConflictResolutionEffect( } HorizontalDivider(modifier = Modifier.padding(vertical = 12.dp)) Text( - "This element was changed by someone else while you were answering. " + - "Choose which value to keep for each question below.", + "This element was changed by someone else while you were answering, so " + + "your answer has not been submitted yet. Choose which value to keep for " + + "each question below - everything is submitted together once you confirm.", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant ) diff --git a/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/DatabaseInitializer.kt b/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/DatabaseInitializer.kt index 21181b7bc31..ccf96d866bd 100644 --- a/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/DatabaseInitializer.kt +++ b/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/DatabaseInitializer.kt @@ -32,7 +32,7 @@ import de.westnordost.streetcomplete.util.logs.Log /** Creates the database and upgrades it */ object DatabaseInitializer { - const val DB_VERSION = 26 + const val DB_VERSION = 24 fun onCreate(db: Database) { // OSM notes @@ -315,19 +315,11 @@ object DatabaseInitializer { } if (oldVersion <= 23 && newVersion >= 24) { + // both tables of the conflict-resolution feature (developed together, released together) db.exec(PendingTagConflictsTable.CREATE) - } - - if (oldVersion <= 24 && newVersion >= 25) { db.exec(DiscardedEditNoticesTable.CREATE) - } - - if (oldVersion <= 25 && newVersion >= 26) { - // element_type/element_id were added to DiscardedEditNoticesTable.CREATE after some - // devices had already created the table at v25 in its original shape. tryExec because - // installs that first created the table at v25 *with* the columns would fail the ALTER - db.tryExec("ALTER TABLE ${DiscardedEditNoticesTable.NAME} ADD COLUMN ${DiscardedEditNoticesTable.Columns.ELEMENT_TYPE} varchar(255)") - db.tryExec("ALTER TABLE ${DiscardedEditNoticesTable.NAME} ADD COLUMN ${DiscardedEditNoticesTable.Columns.ELEMENT_ID} int") + // edits with unresolved tag conflicts are held back from uploading via this flag + db.tryExec("ALTER TABLE osm_element_edits ADD COLUMN blocked_on_conflict int NOT NULL DEFAULT 0") } } } diff --git a/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/osm/edits/ElementEdit.kt b/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/osm/edits/ElementEdit.kt index 91f49c831a7..8e584adc1f3 100644 --- a/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/osm/edits/ElementEdit.kt +++ b/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/osm/edits/ElementEdit.kt @@ -31,7 +31,13 @@ data class ElementEdit( /** Whether the user was near the element that is being edited when this edit was created */ val isNearUserLocation: Boolean, - override var workspaceId: Int = 0 + override var workspaceId: Int = 0, + + /** Whether this (unsynced) edit is excluded from uploading because some of its tag changes + * collided with a concurrent remote edit - nothing of it is uploaded until the user has + * resolved all its pending tag conflicts. The edit still counts as unsynced and is still + * applied to the local map view in the meantime. */ + val isBlockedOnConflict: Boolean = false, ) : Edit { override val isUndoable: Boolean get() = !isSynced || action is IsActionRevertable override val key: ElementEditKey get() = ElementEditKey(id) diff --git a/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/osm/edits/ElementEditsController.kt b/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/osm/edits/ElementEditsController.kt index 68b1b8160e4..4b0ecc9f711 100644 --- a/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/osm/edits/ElementEditsController.kt +++ b/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/osm/edits/ElementEditsController.kt @@ -103,6 +103,24 @@ class ElementEditsController( elementIdProviderDB.updateIds(elementUpdates.idUpdates) } + /** Replace the stored version of the given edit, e.g. when a pending tag conflict resolution + * changed which tag changes it should upload. Must not change which elements the edit + * concerns. */ + fun updateAction(edit: ElementEdit) { + lock.withLock { editsDB.put(edit) } + } + + /** Exclude the given (unsynced) edit from uploading until its pending tag conflicts are + * resolved. It stays unsynced, applied to the local map view, and undoable. */ + fun markBlockedOnConflict(edit: ElementEdit) { + lock.withLock { editsDB.put(edit.copy(isBlockedOnConflict = true)) } + } + + /** Re-include the given edit in uploading, all its pending tag conflicts being resolved */ + fun markUnblocked(edit: ElementEdit) { + lock.withLock { editsDB.put(edit.copy(isBlockedOnConflict = false)) } + } + fun markSyncFailed(edit: ElementEdit) { delete(edit) } diff --git a/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/osm/edits/ElementEditsDao.kt b/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/osm/edits/ElementEditsDao.kt index 14e0ea2a553..cfcbdc115b6 100644 --- a/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/osm/edits/ElementEditsDao.kt +++ b/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/osm/edits/ElementEditsDao.kt @@ -7,6 +7,7 @@ import de.westnordost.streetcomplete.data.osm.edits.ElementEditsTable.Columns.AC import de.westnordost.streetcomplete.data.osm.edits.ElementEditsTable.Columns.CREATED_TIMESTAMP import de.westnordost.streetcomplete.data.osm.edits.ElementEditsTable.Columns.GEOMETRY import de.westnordost.streetcomplete.data.osm.edits.ElementEditsTable.Columns.ID +import de.westnordost.streetcomplete.data.osm.edits.ElementEditsTable.Columns.IS_BLOCKED import de.westnordost.streetcomplete.data.osm.edits.ElementEditsTable.Columns.IS_NEAR_USER_LOCATION import de.westnordost.streetcomplete.data.osm.edits.ElementEditsTable.Columns.IS_SYNCED import de.westnordost.streetcomplete.data.osm.edits.ElementEditsTable.Columns.LATITUDE @@ -72,7 +73,9 @@ class ElementEditsDao( fun getOldestUnsynced(): ElementEdit? = db.queryOne( NAME, - where = "$IS_SYNCED = 0 AND $WORKSPACE_ID = $workspaceId", + // edits blocked on unresolved tag conflicts are skipped by the upload queue but stay + // unsynced (still counted, still applied to the local map view) + where = "$IS_SYNCED = 0 AND $IS_BLOCKED = 0 AND $WORKSPACE_ID = $workspaceId", orderBy = CREATED_TIMESTAMP ) { it.toElementEdit() } @@ -125,7 +128,8 @@ class ElementEditsDao( IS_SYNCED to if (isSynced) 1 else 0, ACTION to json.encodeToString(action), IS_NEAR_USER_LOCATION to if (isNearUserLocation) 1 else 0, - WORKSPACE_ID to workspaceId + WORKSPACE_ID to workspaceId, + IS_BLOCKED to if (isBlockedOnConflict) 1 else 0 ) private fun CursorPosition.toElementEdit() = ElementEdit( @@ -137,6 +141,7 @@ class ElementEditsDao( getInt(IS_SYNCED) == 1, json.decodeFromString(getString(ACTION)), getInt(IS_NEAR_USER_LOCATION) == 1, - getInt(WORKSPACE_ID) + getInt(WORKSPACE_ID), + getInt(IS_BLOCKED) == 1 ) } diff --git a/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/osm/edits/ElementEditsModule.kt b/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/osm/edits/ElementEditsModule.kt index 259dba75e67..76346bc34ce 100644 --- a/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/osm/edits/ElementEditsModule.kt +++ b/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/osm/edits/ElementEditsModule.kt @@ -28,6 +28,6 @@ val elementEditsModule = module { /* must be a singleton because there is a listener that should respond to a change in the * underlying database table */ - single { PendingTagConflictsController(get(), get(), get(), get()) } + single { PendingTagConflictsController(get(), get()) } single { DiscardedEditNoticesController(get()) } } diff --git a/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/osm/edits/ElementEditsTable.kt b/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/osm/edits/ElementEditsTable.kt index dbfef0fffa5..5c1a65d9a70 100644 --- a/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/osm/edits/ElementEditsTable.kt +++ b/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/osm/edits/ElementEditsTable.kt @@ -15,6 +15,9 @@ object ElementEditsTable { const val ACTION = "action" const val IS_NEAR_USER_LOCATION = "is_near" const val WORKSPACE_ID = "workspace_id" + /** whether this (unsynced) edit is excluded from uploading until the user has resolved + * its pending tag conflicts */ + const val IS_BLOCKED = "blocked_on_conflict" } const val CREATE = """ @@ -29,7 +32,8 @@ object ElementEditsTable { ${Columns.IS_SYNCED} int NOT NULL, ${Columns.ACTION} text, ${Columns.IS_NEAR_USER_LOCATION} int NOT NULL, - ${Columns.WORKSPACE_ID} int NOT NULL + ${Columns.WORKSPACE_ID} int NOT NULL, + ${Columns.IS_BLOCKED} int NOT NULL DEFAULT 0 ); """ } diff --git a/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/osm/edits/update_tags/PendingTagConflict.kt b/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/osm/edits/update_tags/PendingTagConflict.kt index 1d7a90132e7..342746e4c99 100644 --- a/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/osm/edits/update_tags/PendingTagConflict.kt +++ b/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/osm/edits/update_tags/PendingTagConflict.kt @@ -5,11 +5,13 @@ import de.westnordost.streetcomplete.data.osm.mapdata.ElementType import de.westnordost.streetcomplete.data.osm.mapdata.LatLon /** A single OSM tag on a specific element where the user's local edit collided with a concurrent - * remote edit on the same key. Held here instead of being discarded together with the rest of - * the edit, so the user can decide - next time the app is in the foreground - whether to keep - * their own answer or the other edit's value. */ + * remote edit on the same key. The whole edit is held back from uploading (blocked) while any of + * these exist for it; once the user has decided per tag - keep their own answer or accept the + * other edit's value - the decisions are folded into the edit and it uploads as one unit. */ data class PendingTagConflict( var id: Long, + /** id of the [de.westnordost.streetcomplete.data.osm.edits.ElementEdit] held back by this conflict */ + val editId: Long, val elementType: ElementType, val elementId: Long, val tagKey: String, diff --git a/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/osm/edits/update_tags/PendingTagConflictsController.kt b/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/osm/edits/update_tags/PendingTagConflictsController.kt index 9251288b478..341884e4267 100644 --- a/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/osm/edits/update_tags/PendingTagConflictsController.kt +++ b/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/osm/edits/update_tags/PendingTagConflictsController.kt @@ -1,26 +1,23 @@ package de.westnordost.streetcomplete.data.osm.edits.update_tags -import de.westnordost.streetcomplete.data.ConflictException -import de.westnordost.streetcomplete.data.osm.edits.upload.changesets.OpenChangesetsManager -import de.westnordost.streetcomplete.data.osm.mapdata.Element -import de.westnordost.streetcomplete.data.osm.mapdata.ElementType -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.edits.ElementEdit +import de.westnordost.streetcomplete.data.osm.edits.ElementEditsController +import de.westnordost.streetcomplete.data.osm.edits.ElementEditsSource import de.westnordost.streetcomplete.util.Listeners import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.IO import kotlinx.coroutines.withContext -/** Holds tag-level edit conflicts that were held back instead of being discarded (see - * [de.westnordost.streetcomplete.data.osm.edits.upload.ElementEditUploader]), and lets the user - * resolve them one at a time - either by re-asserting their own answer or by accepting the - * concurrent remote edit's value. */ +/** Holds tag-level conflicts of edits that are blocked from uploading (see + * [de.westnordost.streetcomplete.data.osm.edits.upload.ElementEditUploader]): while any of these + * exist for an edit, nothing of that edit is uploaded. The user resolves them per tag - keep + * their own answer or accept the concurrent remote edit's value - and each decision is folded + * into the blocked edit's stored action. Once the last conflict of an edit is resolved, the edit + * is unblocked and uploads through the normal sync path as one unit, so the edit history shows + * exactly what was pushed. */ class PendingTagConflictsController( private val dao: PendingTagConflictsDao, - private val mapDataApi: MapDataApiClient, - private val mapDataController: MapDataController, - private val openChangesetsManager: OpenChangesetsManager, + private val elementEditsController: ElementEditsController, ) { interface Listener { fun onAdded(conflict: PendingTagConflict) @@ -29,6 +26,21 @@ class PendingTagConflictsController( private val listeners = Listeners() + init { + // an edit deleted while blocked (e.g. undone from the edit history) takes its pending + // conflicts with it + elementEditsController.addListener(object : ElementEditsSource.Listener { + override fun onAddedEdit(edit: ElementEdit) {} + override fun onSyncedEdit(edit: ElementEdit) {} + override fun onDeletedEdits(edits: List) { + val editIds = edits.mapTo(HashSet()) { it.id } + for (conflict in dao.getAll().filter { it.editId in editIds }) { + remove(conflict) + } + } + }) + } + fun addListener(listener: Listener) { listeners.add(listener) } @@ -44,117 +56,83 @@ class PendingTagConflictsController( fun getAll(): List = dao.getAll() - /** Number of elements with at least one pending conflict, not the number of conflicting tags - - * matches the grouped-by-element dialog (see TagConflictResolutionEffect), where several - * conflicting tags on the same element are shown and resolved together as one */ - fun getCount(): Int = dao.getAll().distinctBy { it.elementType to it.elementId }.size + /** Number of blocked edits with at least one pending conflict, not the number of conflicting + * tags - matches the grouped dialog (see TagConflictResolutionEffect), where all conflicting + * tags of the same edit are shown and resolved together as one */ + fun getCount(): Int = dao.getAll().distinctBy { it.editId }.size /** Returns the oldest pending conflict without removing it - it stays queryable/re-showable * until actually resolved, so it survives the app being killed mid-decision */ fun getOldest(): PendingTagConflict? = dao.getAll().firstOrNull() - /** All pending conflicts for the same element as the oldest one, so they can be shown - and - * decided on - together in a single dialog instead of one at a time */ + /** All pending conflicts of the same (blocked) edit as the oldest one, so they can be shown - + * and decided on - together in a single dialog instead of one at a time */ fun getOldestGroup(): List { val all = dao.getAll() val oldest = all.firstOrNull() ?: return emptyList() - return all.filter { it.elementType == oldest.elementType && it.elementId == oldest.elementId } + return all.filter { it.editId == oldest.editId } } - /** Re-assert the user's own answer for this tag, re-fetching the element fresh first so we - * don't race against a third edit that may have landed since the conflict was detected. - * Returns null if resolved (applied, or turned out to be a no-op), or an updated - * [PendingTagConflict] (with a fresh "theirs" value) if the value changed *again* in the - * meantime and the user should be asked again with up-to-date information. */ - suspend fun resolveKeepMine(conflict: PendingTagConflict): PendingTagConflict? = withContext( - Dispatchers.IO - ) { - val currentElement = fetchElement(conflict.elementType, conflict.elementId) - if (currentElement == null) { - remove(conflict) - return@withContext null - } - val currentValue = currentElement.tags[conflict.tagKey] - val change = buildChange(conflict.tagKey, conflict.mineValue, currentValue) - if (change == null) { - // already matches what the user wants (or both sides agree it should be absent) - remove(conflict) - return@withContext null - } - val updatedElement = currentElement.changesApplied(StringMapChanges(setOf(change))) - val changes = MapDataChanges(modifications = listOf(updatedElement)) - - try { - val updates = uploadWithChangesetRetry(conflict, changes) - mapDataController.updateAll(updates) - remove(conflict) - null - } catch (e: ConflictException) { - // someone changed this tag yet again while we were resolving - re-fetch and let the - // user decide again with current data rather than silently failing - val refetched = fetchElement(conflict.elementType, conflict.elementId) - if (refetched == null) { - remove(conflict) - null - } else { - remove(conflict) - val refreshed = - dao.add(conflict.copy(theirsValueAtDetection = refetched.tags[conflict.tagKey])) - listeners.forEach { it.onAdded(refreshed) } - refreshed - } - } + /** Re-assert the user's own answer for this tag: rebuild the kept change in the blocked + * edit's action against the value the conflict was detected against, so the re-upload's + * conflict check doesn't re-flag it for the very collision the user just decided on. (If a + * *third* value lands before the re-upload, upload-time detection catches it and the edit + * is held again with fresh data.) */ + suspend fun resolveKeepMine(conflict: PendingTagConflict) = withContext(Dispatchers.IO) { + updateBlockedEdit(conflict) { change -> change.rebuiltAgainst(conflict.theirsValueAtDetection) } + remove(conflict) + unblockIfFullyResolved(conflict.editId) } - /** Accept the concurrent remote edit's value - no network call needed, the server already - * has the value being kept */ + /** Accept the concurrent remote edit's value: drop this tag's change from the blocked edit's + * action - the server already has the value being kept */ suspend fun resolveKeepTheirs(conflict: PendingTagConflict) = withContext(Dispatchers.IO) { + updateBlockedEdit(conflict) { null } remove(conflict) + unblockIfFullyResolved(conflict.editId) } - private suspend fun uploadWithChangesetRetry( + /** Rewrite the conflict's tag change within its blocked edit's action; a null result from + * [rewrite] drops the change */ + private fun updateBlockedEdit( conflict: PendingTagConflict, - changes: MapDataChanges, - ) = - try { - val changesetId = openChangesetsManager.getOrCreateChangeset( - conflict.editType, conflict.source, conflict.position, false - ) - mapDataApi.uploadChanges(changesetId, changes) - } catch (e: ConflictException) { - // could be a stale changeset that's been closed in the meantime - try once more with - // a fresh one before giving up and treating it as a real data conflict - val newChangesetId = openChangesetsManager.createChangeset( - conflict.editType, - conflict.source, - conflict.position - ) - mapDataApi.uploadChanges(newChangesetId, changes) + rewrite: (StringMapEntryChange) -> StringMapEntryChange?, + ) { + val edit = elementEditsController.get(conflict.editId) ?: return + val action = edit.action as? UpdateElementTagsAction ?: return + val newChanges = action.changes.changes.mapNotNull { change -> + if (change.key == conflict.tagKey) rewrite(change) else change + }.toSet() + if (newChanges != action.changes.changes) { + elementEditsController.updateAction(edit.copy(action = action.copy(changes = StringMapChanges(newChanges)))) } + } - private suspend fun fetchElement(type: ElementType, id: Long): Element? = when (type) { - ElementType.NODE -> mapDataApi.getNode(id) - ElementType.WAY -> mapDataApi.getWay(id) - ElementType.RELATION -> mapDataApi.getRelation(id) + private fun unblockIfFullyResolved(editId: Long) { + if (dao.getAll().any { it.editId == editId }) return + val edit = elementEditsController.get(editId) ?: return + if (edit.isBlockedOnConflict) elementEditsController.markUnblocked(edit) } private fun remove(conflict: PendingTagConflict) { if (dao.delete(conflict.id)) listeners.forEach { it.onRemoved(conflict) } } +} + +internal fun StringMapEntryChange.mineValue(): String? = when (this) { + is StringMapEntryAdd -> value + is StringMapEntryModify -> value + is StringMapEntryDelete -> null +} - private fun buildChange( - key: String, - mineValue: String?, - currentValue: String?, - ): StringMapEntryChange? = when { - mineValue != null && currentValue != null -> StringMapEntryModify( - key, - currentValue, - mineValue - ) - - mineValue != null && currentValue == null -> StringMapEntryAdd(key, mineValue) - mineValue == null && currentValue != null -> StringMapEntryDelete(key, currentValue) - else -> null +/** The same intended end value (or deletion), but expressed as a diff from [currentValue] instead + * of from whatever this change's original baseline was */ +internal fun StringMapEntryChange.rebuiltAgainst(currentValue: String?): StringMapEntryChange { + val mine = mineValue() + return when { + mine != null && currentValue != null -> StringMapEntryModify(key, currentValue, mine) + mine != null && currentValue == null -> StringMapEntryAdd(key, mine) + mine == null && currentValue != null -> StringMapEntryDelete(key, currentValue) + else -> this } } diff --git a/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/osm/edits/update_tags/PendingTagConflictsDao.kt b/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/osm/edits/update_tags/PendingTagConflictsDao.kt index e5897ccfb46..0c882cad370 100644 --- a/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/osm/edits/update_tags/PendingTagConflictsDao.kt +++ b/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/osm/edits/update_tags/PendingTagConflictsDao.kt @@ -5,6 +5,7 @@ import de.westnordost.streetcomplete.data.CursorPosition import de.westnordost.streetcomplete.data.Database import de.westnordost.streetcomplete.data.osm.edits.ElementEditType import de.westnordost.streetcomplete.data.osm.edits.update_tags.PendingTagConflictsTable.Columns.CREATED_TIMESTAMP +import de.westnordost.streetcomplete.data.osm.edits.update_tags.PendingTagConflictsTable.Columns.EDIT_ID import de.westnordost.streetcomplete.data.osm.edits.update_tags.PendingTagConflictsTable.Columns.ELEMENT_ID import de.westnordost.streetcomplete.data.osm.edits.update_tags.PendingTagConflictsTable.Columns.ELEMENT_TYPE import de.westnordost.streetcomplete.data.osm.edits.update_tags.PendingTagConflictsTable.Columns.ID @@ -51,6 +52,7 @@ class PendingTagConflictsDao( db.delete(NAME, "$WORKSPACE_ID = $workspaceId AND $ID = $id") == 1 private fun PendingTagConflict.toPairs(): List> = listOf( + EDIT_ID to editId, ELEMENT_TYPE to elementType.name, ELEMENT_ID to elementId, PendingTagConflictsTable.Columns.TAG_KEY to tagKey, @@ -66,6 +68,7 @@ class PendingTagConflictsDao( private fun CursorPosition.toPendingTagConflict() = PendingTagConflict( id = getLong(ID), + editId = getLong(EDIT_ID), elementType = ElementType.valueOf(getString(ELEMENT_TYPE)), elementId = getLong(ELEMENT_ID), tagKey = getString(PendingTagConflictsTable.Columns.TAG_KEY), diff --git a/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/osm/edits/update_tags/PendingTagConflictsTable.kt b/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/osm/edits/update_tags/PendingTagConflictsTable.kt index d36bb6349ac..d8bd545b04c 100644 --- a/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/osm/edits/update_tags/PendingTagConflictsTable.kt +++ b/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/osm/edits/update_tags/PendingTagConflictsTable.kt @@ -5,6 +5,7 @@ object PendingTagConflictsTable { object Columns { const val ID = "id" + const val EDIT_ID = "edit_id" const val ELEMENT_TYPE = "element_type" const val ELEMENT_ID = "element_id" const val TAG_KEY = "tag_key" @@ -21,6 +22,7 @@ object PendingTagConflictsTable { const val CREATE = """ CREATE TABLE $NAME ( ${Columns.ID} INTEGER PRIMARY KEY AUTOINCREMENT, + ${Columns.EDIT_ID} int NOT NULL, ${Columns.ELEMENT_TYPE} varchar(255) NOT NULL, ${Columns.ELEMENT_ID} int NOT NULL, ${Columns.TAG_KEY} varchar(255) NOT NULL, diff --git a/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/osm/edits/upload/ElementEditUploader.kt b/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/osm/edits/upload/ElementEditUploader.kt index 548d68eeeb7..287367badef 100644 --- a/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/osm/edits/upload/ElementEditUploader.kt +++ b/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/osm/edits/upload/ElementEditUploader.kt @@ -8,13 +8,11 @@ import de.westnordost.streetcomplete.data.osm.edits.ElementIdProvider import de.westnordost.streetcomplete.data.osm.edits.update_tags.PendingTagConflict import de.westnordost.streetcomplete.data.osm.edits.update_tags.PendingTagConflictsController import de.westnordost.streetcomplete.data.osm.edits.update_tags.StringMapChanges -import de.westnordost.streetcomplete.data.osm.edits.update_tags.StringMapEntryAdd -import de.westnordost.streetcomplete.data.osm.edits.update_tags.StringMapEntryChange -import de.westnordost.streetcomplete.data.osm.edits.update_tags.StringMapEntryDelete -import de.westnordost.streetcomplete.data.osm.edits.update_tags.StringMapEntryModify import de.westnordost.streetcomplete.data.osm.edits.update_tags.UpdateElementTagsAction import de.westnordost.streetcomplete.data.osm.edits.update_tags.changesApplied import de.westnordost.streetcomplete.data.osm.edits.update_tags.isGeometrySubstantiallyDifferent +import de.westnordost.streetcomplete.data.osm.edits.update_tags.mineValue +import de.westnordost.streetcomplete.data.osm.edits.update_tags.rebuiltAgainst import de.westnordost.streetcomplete.data.osm.edits.upload.changesets.OpenChangesetsManager import de.westnordost.streetcomplete.data.osm.mapdata.ChangesetTooLargeException import de.westnordost.streetcomplete.data.osm.mapdata.Element @@ -36,6 +34,9 @@ class ElementEditUploader( /** Apply the given change to the given element and upload it * * @throws ConflictException if element has been changed server-side in an incompatible way + * @throws HeldForConflictResolutionException if some of the edit's tag changes collided with + * a concurrent remote edit - nothing was uploaded, the edit should be blocked until + * the user has resolved the pending conflicts */ suspend fun upload(edit: ElementEdit, getIdProvider: () -> ElementIdProvider): MapDataUpdates { // certain edit types don't allow building changes on top of cached map data @@ -101,11 +102,13 @@ class ElementEditUploader( /** * Applies a tag-update edit onto the element's current (freshly fetched) remote state, but * unlike other edit types, a per-key value collision does not discard the whole edit: - * - keys that don't collide with the current remote tags are merged and uploaded right away * - the two bookkeeping keys are always force-overwritten with the local value, never treated * as a conflict - * - any other genuinely colliding key is held back as a [PendingTagConflict] for the user to - * resolve later, instead of losing the whole batch of answers + * - if any other key genuinely collides, the *whole edit* is held back: nothing is uploaded, + * a [PendingTagConflict] is recorded per colliding key, and + * [HeldForConflictResolutionException] is thrown so the caller blocks the edit until the + * user has decided per tag. The decisions are folded into the edit and it then uploads + * normally, as one unit - so the edit history always shows exactly what was pushed. * * Structural issues (element deleted, geometry changed substantially) are still a hard, * all-or-nothing failure - there's no sensible per-tag override for those. @@ -131,45 +134,42 @@ class ElementEditUploader( }.toSet() val realConflicts = StringMapChanges(reconciledChanges).getConflictsTo(currentElement.tags).toSet() - val safeChanges = StringMapChanges(reconciledChanges - realConflicts) - val updates = if (safeChanges.isEmpty()) { - // nothing could be merged - just refresh the local cache to the current server state - MapDataUpdates(updated = listOf(currentElement)) - } else { - val updatedElement = currentElement.changesApplied(safeChanges) - val changes = MapDataChanges(modifications = listOf(updatedElement)) - try { - uploadChanges(edit, changes, false) - } - // probably changeset was closed -> try again once with new changeset - catch (e: ConflictException) { - uploadChanges(edit, changes, true) - } - catch (e: ChangesetTooLargeException) { - uploadChanges(edit, changes, true) + if (realConflicts.isNotEmpty()) { + for (conflict in realConflicts) { + pendingTagConflictsController.add( + PendingTagConflict( + id = 0, + editId = edit.id, + elementType = currentElement.type, + elementId = currentElement.id, + tagKey = conflict.key, + mineValue = conflict.mineValue(), + theirsValueAtDetection = currentElement.tags[conflict.key], + editType = edit.type, + source = edit.source, + position = edit.position, + createdTimestamp = nowAsEpochMilliseconds(), + workspaceId = edit.workspaceId + ) + ) } + throw HeldForConflictResolutionException(currentElement) } - for (conflict in realConflicts) { - pendingTagConflictsController.add( - PendingTagConflict( - id = 0, - elementType = currentElement.type, - elementId = currentElement.id, - tagKey = conflict.key, - mineValue = conflict.mineValue(), - theirsValueAtDetection = currentElement.tags[conflict.key], - editType = edit.type, - source = edit.source, - position = edit.position, - createdTimestamp = nowAsEpochMilliseconds(), - workspaceId = edit.workspaceId - ) - ) + val changes = MapDataChanges( + modifications = listOf(currentElement.changesApplied(StringMapChanges(reconciledChanges))) + ) + return try { + uploadChanges(edit, changes, false) + } + // probably changeset was closed -> try again once with new changeset + catch (e: ConflictException) { + uploadChanges(edit, changes, true) + } + catch (e: ChangesetTooLargeException) { + uploadChanges(edit, changes, true) } - - return updates } private suspend fun fetchElement(type: ElementType, id: Long): Element? = when (type) { @@ -199,18 +199,9 @@ class ElementEditUploader( } } -private fun StringMapEntryChange.mineValue(): String? = when (this) { - is StringMapEntryAdd -> value - is StringMapEntryModify -> value - is StringMapEntryDelete -> null -} - -private fun StringMapEntryChange.rebuiltAgainst(currentValue: String?): StringMapEntryChange { - val mine = mineValue() - return when { - mine != null && currentValue != null -> StringMapEntryModify(key, currentValue, mine) - mine != null && currentValue == null -> StringMapEntryAdd(key, mine) - mine == null && currentValue != null -> StringMapEntryDelete(key, currentValue) - else -> this - } -} +/** Thrown when an edit's tag changes collided with a concurrent remote edit: nothing was + * uploaded, [PendingTagConflict]s were recorded, and the edit should be blocked from uploading + * until the user has resolved them. Carries the freshly fetched [currentElement] so the caller + * can refresh the local cache to the state the conflicts were detected against. */ +class HeldForConflictResolutionException(val currentElement: Element) : + RuntimeException("Edit held back until its tag conflicts are resolved") diff --git a/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/osm/edits/upload/ElementEditsUploader.kt b/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/osm/edits/upload/ElementEditsUploader.kt index af83d07db38..678dfbae132 100644 --- a/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/osm/edits/upload/ElementEditsUploader.kt +++ b/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/osm/edits/upload/ElementEditsUploader.kt @@ -72,6 +72,13 @@ class ElementEditsUploader( } else { statisticsController.addOne(edit.type.name, edit.position) } + } catch (e: HeldForConflictResolutionException) { + // nothing was uploaded and nothing is discarded: the edit is excluded from uploading + // until the user has resolved its pending tag conflicts, then re-enters this queue + Log.d(TAG, "Held a $editActionClassName for conflict resolution") + elementEditsController.markBlockedOnConflict(edit) + // refresh the local cache to the remote state the conflicts were detected against + mapDataController.updateAll(MapDataUpdates(updated = listOf(e.currentElement))) } catch (e: ConflictException) { Log.d(TAG, "Dropped a $editActionClassName: ${e.message}") uploadedChangeListener?.onDiscarded(edit.type.name, edit.position) From 62b73443c8fa76df3a9237de4ae68cea174b8e7f Mon Sep 17 00:00:00 2001 From: Rajesh Kantipudi <44539669+iamrajeshk@users.noreply.github.com> Date: Thu, 9 Jul 2026 18:28:10 +0530 Subject: [PATCH 18/19] Integrate KartaView API for image uploads; refactor image upload logic and update UI components --- app/src/androidDebug/res/values/conf.xml | 2 +- app/src/androidDebug/res/xml/file_paths.xml | 4 - .../streetcomplete/ApplicationModule.kt | 3 + .../quests/AbstractOsmQuestForm.kt | 170 ++++-------------- .../res/layout/form_leave_note.xml | 7 + app/src/androidMain/res/values/conf.xml | 2 +- app/src/androidMain/res/xml/file_paths.xml | 4 +- .../data/karta_view/KartaViewApiClient.kt | 138 ++++++++++++++ .../domain/model/CreateSequenceResponse.kt | 0 .../domain/model/ImageUploadResponse.kt | 0 .../data/karta_view/domain/model/Osv.kt | 0 .../data/karta_view/domain/model/OsvX.kt | 0 .../data/karta_view/domain/model/Photo.kt | 0 .../domain/model/PhotoLookupResponse.kt | 0 .../data/karta_view/domain/model/Sequence.kt | 0 .../data/karta_view/domain/model/Status.kt | 0 .../data/karta_view/domain/model/StatusX.kt | 0 .../data/osmnotes/NotesModule.kt | 2 - .../data/osmnotes/edits/NoteEditsUploader.kt | 20 +-- 19 files changed, 193 insertions(+), 159 deletions(-) delete mode 100644 app/src/androidDebug/res/xml/file_paths.xml create mode 100644 app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/karta_view/KartaViewApiClient.kt rename app/src/{androidMain => commonMain}/kotlin/de/westnordost/streetcomplete/data/karta_view/domain/model/CreateSequenceResponse.kt (100%) rename app/src/{androidMain => commonMain}/kotlin/de/westnordost/streetcomplete/data/karta_view/domain/model/ImageUploadResponse.kt (100%) rename app/src/{androidMain => commonMain}/kotlin/de/westnordost/streetcomplete/data/karta_view/domain/model/Osv.kt (100%) rename app/src/{androidMain => commonMain}/kotlin/de/westnordost/streetcomplete/data/karta_view/domain/model/OsvX.kt (100%) rename app/src/{androidMain => commonMain}/kotlin/de/westnordost/streetcomplete/data/karta_view/domain/model/Photo.kt (100%) rename app/src/{androidMain => commonMain}/kotlin/de/westnordost/streetcomplete/data/karta_view/domain/model/PhotoLookupResponse.kt (100%) rename app/src/{androidMain => commonMain}/kotlin/de/westnordost/streetcomplete/data/karta_view/domain/model/Sequence.kt (100%) rename app/src/{androidMain => commonMain}/kotlin/de/westnordost/streetcomplete/data/karta_view/domain/model/Status.kt (100%) rename app/src/{androidMain => commonMain}/kotlin/de/westnordost/streetcomplete/data/karta_view/domain/model/StatusX.kt (100%) 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..7ab8599724b 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()) } 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/res/layout/form_leave_note.xml b/app/src/androidMain/res/layout/form_leave_note.xml index efc9130b0d5..d92be965f84 100644 --- a/app/src/androidMain/res/layout/form_leave_note.xml +++ b/app/src/androidMain/res/layout/form_leave_note.xml @@ -24,4 +24,11 @@ android:background="@drawable/background_edittext_outline" /> + + 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/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") } From 0a4142a3b2df027e58070eb5a7c96e00c2391cae Mon Sep 17 00:00:00 2001 From: Rajesh Kantipudi <44539669+iamrajeshk@users.noreply.github.com> Date: Fri, 10 Jul 2026 08:04:29 +0530 Subject: [PATCH 19/19] Add KartaView access token handling; update Preferences and create secrets.properties.example --- .gitignore | 1 + app/build.gradle.kts | 9 +++++++++ .../streetcomplete/data/preferences/Preferences.kt | 5 ++--- secrets.properties.example | 8 ++++++++ 4 files changed, 20 insertions(+), 3 deletions(-) create mode 100644 secrets.properties.example 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 36ccd7ec231..42bc99c1d54 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -54,6 +54,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 +70,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/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/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=