diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 50dedaf408a..c1488b92130 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -173,6 +173,7 @@ dependencies { implementation("de.westnordost:osmapi-map:2.0") implementation("de.westnordost:osmapi-changesets:2.0") implementation("de.westnordost:osmapi-notes:2.0") + implementation("de.westnordost:osmapi-traces:2.0") implementation("de.westnordost:osmapi-user:2.0") implementation("com.squareup.okhttp3:okhttp:3.14.9") implementation("se.akerfeldt:okhttp-signpost:1.1.0") diff --git a/app/src/main/assets/map_theme/jawg/streetcomplete.yaml b/app/src/main/assets/map_theme/jawg/streetcomplete.yaml index 9c8a236e631..dc2dadab73b 100644 --- a/app/src/main/assets/map_theme/jawg/streetcomplete.yaml +++ b/app/src/main/assets/map_theme/jawg/streetcomplete.yaml @@ -3,6 +3,7 @@ global: geometry_line_color: '#44D14000' geometry_point_color: '#88D14000' track_color: '#44536dfe' + track_color_record: '#44fe1616' old_track_color: '#22536dfe' textures: @@ -126,7 +127,7 @@ layers: streetcomplete_track: data: { source: streetcomplete_track } current: - filter: { old: [false] } + filter: { old: [false], record: [false] } draw: track-lines: color: global.track_color @@ -134,8 +135,17 @@ layers: collide: false join: round order: 1000 + record: + filter: { record: [true] } + draw: + track-lines: + color: global.track_color_record + width: [[14, 6px],[18, 12px]] + collide: false + join: round + order: 1000 old: - filter: { old: [true] } + filter: { old: [true], record: [false] } draw: track-lines: color: global.old_track_color @@ -146,7 +156,7 @@ layers: streetcomplete_track2: data: { source: streetcomplete_track2 } current: - filter: { old: [false] } + filter: { old: [false], record: [false] } draw: track-lines: color: global.track_color @@ -154,8 +164,17 @@ layers: collide: false join: round order: 1000 + record: + filter: { record: [true] } + draw: + track-lines: + color: global.track_color_record + width: [[14, 6px],[18, 12px]] + collide: false + join: round + order: 1000 old: - filter: { old: [true] } + filter: { old: [true], record: [false] } draw: track-lines: color: global.old_track_color diff --git a/app/src/main/java/de/westnordost/streetcomplete/Prefs.kt b/app/src/main/java/de/westnordost/streetcomplete/Prefs.kt index dfb4f7f4e42..4b0518f1bc3 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/Prefs.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/Prefs.kt @@ -21,6 +21,7 @@ object Prefs { const val OSM_USER_ID = "osm.userid" const val OSM_USER_NAME = "osm.username" const val OSM_UNREAD_MESSAGES = "osm.unread_messages" + const val OSM_HAS_UPLOAD_TRACES_PERMISSION = "osm.upload_traces_permission" const val USER_DAYS_ACTIVE = "days_active" const val USER_GLOBAL_RANK = "user_global_rank" const val USER_LAST_TIMESTAMP_ACTIVE = "last_timestamp_active" diff --git a/app/src/main/java/de/westnordost/streetcomplete/data/OsmApiModule.kt b/app/src/main/java/de/westnordost/streetcomplete/data/OsmApiModule.kt index 8e53245e434..d6bdf825d4d 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/data/OsmApiModule.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/data/OsmApiModule.kt @@ -7,6 +7,8 @@ import de.westnordost.streetcomplete.data.osm.mapdata.MapDataApi import de.westnordost.streetcomplete.data.osm.mapdata.MapDataApiImpl import de.westnordost.streetcomplete.data.osmnotes.NotesApi import de.westnordost.streetcomplete.data.osmnotes.NotesApiImpl +import de.westnordost.streetcomplete.data.osmtracks.TracksApi +import de.westnordost.streetcomplete.data.osmtracks.TracksApiImpl import de.westnordost.streetcomplete.data.user.OAuthStore import oauth.signpost.OAuthConsumer import org.koin.androidx.workmanager.dsl.worker @@ -17,6 +19,7 @@ val osmApiModule = module { factory { Cleaner(get(), get(), get()) } factory { MapDataApiImpl(get()) } factory { NotesApiImpl(get()) } + factory { TracksApiImpl(get()) } factory { Preloader(get(named("CountryBoundariesFuture")), get(named("FeatureDictionaryFuture"))) } factory { UserApi(get()) } diff --git a/app/src/main/java/de/westnordost/streetcomplete/data/StreetCompleteSQLiteOpenHelper.kt b/app/src/main/java/de/westnordost/streetcomplete/data/StreetCompleteSQLiteOpenHelper.kt index e54dfa708d6..287c62c603d 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/data/StreetCompleteSQLiteOpenHelper.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/data/StreetCompleteSQLiteOpenHelper.kt @@ -171,7 +171,10 @@ class StreetCompleteSQLiteOpenHelper(context: Context, dbName: String) : ) db.execSQL("DROP TABLE $oldGeometryTableName;") } + if (oldVersion <= 5 && newVersion > 5) { + db.execSQL("ALTER TABLE ${NoteEditsTable.NAME} ADD COLUMN ${NoteEditsTable.Columns.TRACK} text DEFAULT '[]' NOT NULL") + } } } -private const val DB_VERSION = 5 +private const val DB_VERSION = 6 diff --git a/app/src/main/java/de/westnordost/streetcomplete/data/osmnotes/edits/NoteEdit.kt b/app/src/main/java/de/westnordost/streetcomplete/data/osmnotes/edits/NoteEdit.kt index 6bf676f1460..0ae91f90f37 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/data/osmnotes/edits/NoteEdit.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/data/osmnotes/edits/NoteEdit.kt @@ -3,6 +3,7 @@ package de.westnordost.streetcomplete.data.osmnotes.edits import de.westnordost.streetcomplete.data.edithistory.Edit import de.westnordost.streetcomplete.data.edithistory.NoteEditKey import de.westnordost.streetcomplete.data.osm.mapdata.LatLon +import de.westnordost.streetcomplete.data.osmtracks.Trackpoint /** Contains all necessary information to create/comment on an OSM note. */ data class NoteEdit( @@ -31,8 +32,11 @@ data class NoteEdit( /** whether this edit has been uploaded already */ override val isSynced: Boolean, - /** Whether the images attached still need activation. Already true if imagePaths is empty */ - val imagesNeedActivation: Boolean + /** whether the images attached still need activation. Already true if imagePaths is empty */ + val imagesNeedActivation: Boolean, + + /** attached GPS location history */ + val track: List, ) : Edit { override val isUndoable: Boolean get() = !isSynced override val key: NoteEditKey get() = NoteEditKey(id) diff --git a/app/src/main/java/de/westnordost/streetcomplete/data/osmnotes/edits/NoteEditsController.kt b/app/src/main/java/de/westnordost/streetcomplete/data/osmnotes/edits/NoteEditsController.kt index 6970da858a5..38beff78450 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/data/osmnotes/edits/NoteEditsController.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/data/osmnotes/edits/NoteEditsController.kt @@ -3,6 +3,7 @@ package de.westnordost.streetcomplete.data.osmnotes.edits import de.westnordost.streetcomplete.data.osm.mapdata.BoundingBox import de.westnordost.streetcomplete.data.osm.mapdata.LatLon import de.westnordost.streetcomplete.data.osmnotes.Note +import de.westnordost.streetcomplete.data.osmtracks.Trackpoint import java.lang.System.currentTimeMillis import java.util.concurrent.CopyOnWriteArrayList @@ -19,7 +20,8 @@ class NoteEditsController( action: NoteEditAction, position: LatLon, text: String? = null, - imagePaths: List = emptyList() + imagePaths: List = emptyList(), + track: List = emptyList(), ) { val edit = NoteEdit( 0, @@ -30,7 +32,8 @@ class NoteEditsController( imagePaths, currentTimeMillis(), false, - imagePaths.isNotEmpty() + imagePaths.isNotEmpty(), + track, ) synchronized(this) { editsDB.add(edit) } onAddedEdit(edit) diff --git a/app/src/main/java/de/westnordost/streetcomplete/data/osmnotes/edits/NoteEditsDao.kt b/app/src/main/java/de/westnordost/streetcomplete/data/osmnotes/edits/NoteEditsDao.kt index 816b8d57f09..e85428740f8 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/data/osmnotes/edits/NoteEditsDao.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/data/osmnotes/edits/NoteEditsDao.kt @@ -13,6 +13,7 @@ import de.westnordost.streetcomplete.data.osmnotes.edits.NoteEditsTable.Columns. import de.westnordost.streetcomplete.data.osmnotes.edits.NoteEditsTable.Columns.LONGITUDE import de.westnordost.streetcomplete.data.osmnotes.edits.NoteEditsTable.Columns.NOTE_ID import de.westnordost.streetcomplete.data.osmnotes.edits.NoteEditsTable.Columns.TEXT +import de.westnordost.streetcomplete.data.osmnotes.edits.NoteEditsTable.Columns.TRACK import de.westnordost.streetcomplete.data.osmnotes.edits.NoteEditsTable.Columns.TYPE import de.westnordost.streetcomplete.data.osmnotes.edits.NoteEditsTable.NAME import kotlinx.serialization.decodeFromString @@ -123,6 +124,7 @@ class NoteEditsDao(private val db: Database) { TEXT to text, IMAGE_PATHS to Json.encodeToString(imagePaths), IMAGES_NEED_ACTIVATION to if (imagesNeedActivation) 1 else 0, + TRACK to Json.encodeToString(track), TYPE to action.name ) @@ -135,6 +137,7 @@ class NoteEditsDao(private val db: Database) { Json.decodeFromString(getString(IMAGE_PATHS)), getLong(CREATED_TIMESTAMP), getInt(IS_SYNCED) == 1, - getInt(IMAGES_NEED_ACTIVATION) == 1 + getInt(IMAGES_NEED_ACTIVATION) == 1, + Json.decodeFromString(getString(TRACK)), ) } diff --git a/app/src/main/java/de/westnordost/streetcomplete/data/osmnotes/edits/NoteEditsModule.kt b/app/src/main/java/de/westnordost/streetcomplete/data/osmnotes/edits/NoteEditsModule.kt index 4f08a7a26d9..df1480224d4 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/data/osmnotes/edits/NoteEditsModule.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/data/osmnotes/edits/NoteEditsModule.kt @@ -5,7 +5,7 @@ import org.koin.dsl.module val noteEditsModule = module { factory { NoteEditsDao(get()) } - single { NoteEditsUploader(get(), get(), get(), get()) } + single { NoteEditsUploader(get(), get(), get(), get(), get()) } single { NoteEditsController(get()) } single { get() } single { NotesWithEditsSource(get(), get(), get()) } diff --git a/app/src/main/java/de/westnordost/streetcomplete/data/osmnotes/edits/NoteEditsTable.kt b/app/src/main/java/de/westnordost/streetcomplete/data/osmnotes/edits/NoteEditsTable.kt index 94a24a2b119..36166dabbb8 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/data/osmnotes/edits/NoteEditsTable.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/data/osmnotes/edits/NoteEditsTable.kt @@ -14,6 +14,7 @@ object NoteEditsTable { const val TEXT = "text" const val IMAGE_PATHS = "image_paths" const val IMAGES_NEED_ACTIVATION = "images_need_activation" + const val TRACK = "track" } const val CREATE = """ @@ -27,6 +28,7 @@ object NoteEditsTable { ${Columns.TEXT} text, ${Columns.IMAGE_PATHS} text NOT NULL, ${Columns.IMAGES_NEED_ACTIVATION} int NOT NULL, + ${Columns.TRACK} text NOT NULL, ${Columns.TYPE} varchar(255) ); """ diff --git a/app/src/main/java/de/westnordost/streetcomplete/data/osmnotes/edits/NoteEditsUploader.kt b/app/src/main/java/de/westnordost/streetcomplete/data/osmnotes/edits/NoteEditsUploader.kt index 1105dcdcf9f..740bd9b49d1 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/data/osmnotes/edits/NoteEditsUploader.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/data/osmnotes/edits/NoteEditsUploader.kt @@ -7,6 +7,8 @@ import de.westnordost.streetcomplete.data.osmnotes.StreetCompleteImageUploader import de.westnordost.streetcomplete.data.osmnotes.deleteImages import de.westnordost.streetcomplete.data.osmnotes.edits.NoteEditAction.COMMENT import de.westnordost.streetcomplete.data.osmnotes.edits.NoteEditAction.CREATE +import de.westnordost.streetcomplete.data.osmtracks.Trackpoint +import de.westnordost.streetcomplete.data.osmtracks.TracksApi import de.westnordost.streetcomplete.data.upload.ConflictException import de.westnordost.streetcomplete.data.upload.OnUploadedChangeListener import kotlinx.coroutines.CoroutineName @@ -21,6 +23,7 @@ class NoteEditsUploader( private val noteEditsController: NoteEditsController, private val noteController: NoteController, private val notesApi: NotesApi, + private val tracksApi: TracksApi, private val imageUploader: StreetCompleteImageUploader ) { var uploadedChangeListener: OnUploadedChangeListener? = null @@ -62,8 +65,12 @@ class NoteEditsUploader( } private fun uploadEdit(edit: NoteEdit) { - val text = edit.text.orEmpty() + uploadAndGetAttachedPhotosText(edit.imagePaths) + // try to upload the image and track if we have them + val imageText = uploadAndGetAttachedPhotosText(edit.imagePaths) + val trackText = uploadAndGetAttachedTrackText(edit.track, edit.text) + val text = edit.text.orEmpty() + imageText + trackText + // done, try to upload the note to OSM try { val note = when (edit.action) { CREATE -> notesApi.create(edit.position, text) @@ -112,6 +119,15 @@ class NoteEditsUploader( return "" } + private fun uploadAndGetAttachedTrackText( + trackpoints: List, + noteText: String? + ): String { + if (trackpoints.isEmpty()) return "" + val track = tracksApi.create(trackpoints, noteText) + return "\n\nGPS Trace: \nhttps://www.openstreetmap.org/user/${track.userName}/traces/${track.id}" + } + companion object { private const val TAG = "NoteEditsUploader" private const val NOTE = "NOTE" diff --git a/app/src/main/java/de/westnordost/streetcomplete/data/osmtracks/Track.kt b/app/src/main/java/de/westnordost/streetcomplete/data/osmtracks/Track.kt new file mode 100644 index 00000000000..4d1511fa237 --- /dev/null +++ b/app/src/main/java/de/westnordost/streetcomplete/data/osmtracks/Track.kt @@ -0,0 +1,18 @@ +package de.westnordost.streetcomplete.data.osmtracks + +import de.westnordost.streetcomplete.data.osm.mapdata.LatLon +import kotlinx.serialization.Serializable + +@Serializable +data class Track( + val id: Long, + val userName: String, +) + +@Serializable +data class Trackpoint( + val position: LatLon, + val time: Long, + val horizontalDilutionOfPrecision: Float, + val elevation: Float, +) diff --git a/app/src/main/java/de/westnordost/streetcomplete/data/osmtracks/TracksApi.kt b/app/src/main/java/de/westnordost/streetcomplete/data/osmtracks/TracksApi.kt new file mode 100644 index 00000000000..c923ad641a6 --- /dev/null +++ b/app/src/main/java/de/westnordost/streetcomplete/data/osmtracks/TracksApi.kt @@ -0,0 +1,24 @@ +package de.westnordost.streetcomplete.data.osmtracks + +import de.westnordost.streetcomplete.data.download.ConnectionException +import de.westnordost.streetcomplete.data.user.AuthorizationException + +/** + * Creates GPS / GPX trackpoint histories + */ +interface TracksApi { + + /** + * Create a new GPX track history + * + * @param trackpoints history of recorded trackpoints + * @param noteText optional text appended to the track + * + * @throws AuthorizationException if this application is not authorized to write notes + * (Permission.READ_GPS_TRACES, Permission.WRITE_GPS_TRACES) + * @throws ConnectionException if a temporary network connection problem occurs + * + * @return the new track + */ + fun create(trackpoints: List, noteText: String?): Track +} diff --git a/app/src/main/java/de/westnordost/streetcomplete/data/osmtracks/TracksApiImpl.kt b/app/src/main/java/de/westnordost/streetcomplete/data/osmtracks/TracksApiImpl.kt new file mode 100644 index 00000000000..6c5512ef700 --- /dev/null +++ b/app/src/main/java/de/westnordost/streetcomplete/data/osmtracks/TracksApiImpl.kt @@ -0,0 +1,64 @@ +package de.westnordost.streetcomplete.data.osmtracks + +import de.westnordost.osmapi.OsmConnection +import de.westnordost.osmapi.common.errors.OsmApiException +import de.westnordost.osmapi.common.errors.OsmApiReadResponseException +import de.westnordost.osmapi.common.errors.OsmAuthorizationException +import de.westnordost.osmapi.common.errors.OsmConnectionException +import de.westnordost.osmapi.map.data.OsmLatLon +import de.westnordost.osmapi.traces.GpsTraceDetails +import de.westnordost.osmapi.traces.GpsTracesApi +import de.westnordost.osmapi.traces.GpsTrackpoint +import de.westnordost.streetcomplete.ApplicationConstants +import de.westnordost.streetcomplete.data.download.ConnectionException +import de.westnordost.streetcomplete.data.user.AuthorizationException +import java.time.Instant +import java.time.ZoneOffset +import java.time.format.DateTimeFormatter + +class TracksApiImpl(osm: OsmConnection) : TracksApi { + private val api: GpsTracesApi = GpsTracesApi(osm) + + override fun create(trackpoints: List, noteText: String?): Track = wrapExceptions { + // Filename is just the start of the track + // https://stackoverflow.com/a/49862573/7718197 + val name = DateTimeFormatter + .ofPattern("yyyy_MM_dd'T'HH_mm_ss.SSSSSS'Z'") + .withZone(ZoneOffset.UTC) + .format(Instant.ofEpochSecond(trackpoints[0].time)) + ".gpx" + val visibility = GpsTraceDetails.Visibility.IDENTIFIABLE + val description = noteText ?: "Uploaded via ${ApplicationConstants.USER_AGENT}" + val tags = listOf(ApplicationConstants.NAME.lowercase()) + + // Generate history of trackpoints + val history = trackpoints.mapIndexed { idx, it -> + GpsTrackpoint( + OsmLatLon(it.position.latitude, it.position.longitude), + Instant.ofEpochMilli(it.time), + idx == 0, + it.horizontalDilutionOfPrecision, + it.elevation + ) + } + + // Finally query the API and return! + val traceId = api.create(name, visibility, description, tags, history) + val details = api.get(traceId) + Track(details.id, details.userName) + } +} + +private inline fun wrapExceptions(block: () -> T): T = + try { + block() + } catch (e: OsmAuthorizationException) { + throw AuthorizationException(e.message, e) + } catch (e: OsmConnectionException) { + throw ConnectionException(e.message, e) + } catch (e: OsmApiReadResponseException) { + // probably a temporary connection error + throw ConnectionException(e.message, e) + } catch (e: OsmApiException) { + // request timeout is a temporary connection error + throw if (e.errorCode == 408) ConnectionException(e.message, e) else e + } diff --git a/app/src/main/java/de/westnordost/streetcomplete/data/quest/QuestController.kt b/app/src/main/java/de/westnordost/streetcomplete/data/quest/QuestController.kt index 099f44c3869..0b93232697e 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/data/quest/QuestController.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/data/quest/QuestController.kt @@ -20,12 +20,12 @@ import de.westnordost.streetcomplete.data.osmnotes.edits.NoteEditAction import de.westnordost.streetcomplete.data.osmnotes.edits.NoteEditsController import de.westnordost.streetcomplete.data.osmnotes.notequests.OsmNoteQuest import de.westnordost.streetcomplete.data.osmnotes.notequests.OsmNoteQuestController +import de.westnordost.streetcomplete.data.osmtracks.Trackpoint import de.westnordost.streetcomplete.osm.KEYS_THAT_SHOULD_BE_REMOVED_WHEN_SHOP_IS_REPLACED import de.westnordost.streetcomplete.osm.removeCheckDates import de.westnordost.streetcomplete.quests.note_discussion.NoteAnswer import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext -import kotlin.collections.ArrayList /** Controls the workflow of quests: Solving them, hiding them instead, splitting the way instead, * undoing, etc. */ @@ -66,10 +66,11 @@ class QuestController( suspend fun createNote( text: String, imagePaths: List, - position: LatLon + position: LatLon, + track: List, ) = withContext(Dispatchers.IO) { val fullText = "$text\n\nvia ${ApplicationConstants.USER_AGENT}" - noteEditsController.add(0, NoteEditAction.CREATE, position, fullText, imagePaths) + noteEditsController.add(0, NoteEditAction.CREATE, position, fullText, imagePaths, track) } /** Split a way for the given OSM Quest. diff --git a/app/src/main/java/de/westnordost/streetcomplete/data/user/UserLoginStatusController.kt b/app/src/main/java/de/westnordost/streetcomplete/data/user/UserLoginStatusController.kt index 5a36be86a43..eb4cef4c993 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/data/user/UserLoginStatusController.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/data/user/UserLoginStatusController.kt @@ -1,12 +1,15 @@ package de.westnordost.streetcomplete.data.user +import android.content.SharedPreferences import de.westnordost.osmapi.OsmConnection +import de.westnordost.streetcomplete.Prefs import oauth.signpost.OAuthConsumer import java.util.concurrent.CopyOnWriteArrayList class UserLoginStatusController( private val oAuthStore: OAuthStore, private val osmConnection: OsmConnection, + private val prefs: SharedPreferences, ) : UserLoginStatusSource { private val listeners: MutableList = CopyOnWriteArrayList() @@ -16,12 +19,14 @@ class UserLoginStatusController( fun logIn(consumer: OAuthConsumer) { oAuthStore.oAuthConsumer = consumer osmConnection.oAuth = consumer + prefs.edit().putBoolean(Prefs.OSM_HAS_UPLOAD_TRACES_PERMISSION, true).apply() listeners.forEach { it.onLoggedIn() } } fun logOut() { oAuthStore.oAuthConsumer = null osmConnection.oAuth = null + prefs.edit().putBoolean(Prefs.OSM_HAS_UPLOAD_TRACES_PERMISSION, false).apply() listeners.forEach { it.onLoggedOut() } } diff --git a/app/src/main/java/de/westnordost/streetcomplete/data/user/UserModule.kt b/app/src/main/java/de/westnordost/streetcomplete/data/user/UserModule.kt index 40f91415677..b164d55f0f4 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/data/user/UserModule.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/data/user/UserModule.kt @@ -28,7 +28,7 @@ val userModule = module { single { UserDataController(get(), get()) } single { get() } - single { UserLoginStatusController(get(), get()) } + single { UserLoginStatusController(get(), get(), get()) } single { UserUpdater(get(), get(), get(), get(), get()) } } diff --git a/app/src/main/java/de/westnordost/streetcomplete/screens/main/MainFragment.kt b/app/src/main/java/de/westnordost/streetcomplete/screens/main/MainFragment.kt index f087a5eb702..f5b63815968 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/screens/main/MainFragment.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/screens/main/MainFragment.kt @@ -26,6 +26,7 @@ import androidx.core.graphics.toPointF import androidx.core.graphics.toRectF import androidx.core.view.isGone import androidx.core.view.isInvisible +import androidx.core.view.isVisible import androidx.fragment.app.Fragment import androidx.fragment.app.FragmentManager import androidx.fragment.app.commit @@ -84,6 +85,7 @@ import de.westnordost.streetcomplete.util.ktx.hasLocationPermission import de.westnordost.streetcomplete.util.ktx.hideKeyboard import de.westnordost.streetcomplete.util.ktx.isLocationEnabled import de.westnordost.streetcomplete.util.ktx.setMargins +import de.westnordost.streetcomplete.util.ktx.toLatLon import de.westnordost.streetcomplete.util.ktx.toast import de.westnordost.streetcomplete.util.ktx.viewLifecycleScope import de.westnordost.streetcomplete.util.location.FineLocationManager @@ -93,10 +95,12 @@ import de.westnordost.streetcomplete.util.math.area import de.westnordost.streetcomplete.util.math.enclosingBoundingBox import de.westnordost.streetcomplete.util.math.initialBearingTo import de.westnordost.streetcomplete.util.viewBinding +import de.westnordost.streetcomplete.view.dialogs.RequestPermissionUpgradeDialog import de.westnordost.streetcomplete.view.insets_animation.respectSystemInsets import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import org.koin.android.ext.android.get import org.koin.android.ext.android.inject import kotlin.math.PI import kotlin.math.abs @@ -193,6 +197,7 @@ class MainFragment : binding.compassView.setOnClickListener { onClickCompassButton() } binding.gpsTrackingButton.setOnClickListener { onClickTrackingButton() } + binding.stopTracksButton.setOnClickListener { onClickTracksStop() } binding.zoomInButton.setOnClickListener { onClickZoomIn() } binding.zoomOutButton.setOnClickListener { onClickZoomOut() } binding.answersCounterFragment.setOnClickListener { starInfoMenu() } @@ -265,6 +270,7 @@ class MainFragment : override fun onMapInitialized() { binding.gpsTrackingButton.isActivated = mapFragment?.isFollowingPosition ?: false binding.gpsTrackingButton.isNavigation = mapFragment?.isNavigationMode ?: false + binding.stopTracksButton.isVisible = mapFragment?.isRecordingTracks ?: false updateLocationPointerPin() listener?.onMapInitialized() } @@ -517,7 +523,9 @@ class MainFragment : */ closeBottomSheet() - viewLifecycleScope.launch { questController.createNote(note, imagePaths, position) } + viewLifecycleScope.launch { + questController.createNote(note, imagePaths, position, mapFragment.recordedTracks) + } listener?.onCreatedNote(screenPosition) showMarkerSolvedAnimation(R.drawable.ic_quest_create_note, PointF(screenPosition)) @@ -626,6 +634,15 @@ class MainFragment : mapFragment?.updateCameraPosition(300) { zoomBy = +1f } } + private fun onClickTracksStop() { + // hide the track information + binding.stopTracksButton.isVisible = false + val mapFragment = mapFragment ?: return + mapFragment.stopPositionTrackRecording() + val pos = mapFragment.displayedLocation?.toLatLon() ?: return + composeNote(pos) + } + private fun onClickCompassButton() { /* Clicking the compass button will always rotate the map back to north and remove tilt */ val mapFragment = mapFragment ?: return @@ -686,6 +703,7 @@ class MainFragment : popupMenu.setOnMenuItemClickListener { item -> when (item.itemId) { R.id.action_create_note -> onClickCreateNote(position) + R.id.action_create_track -> onClickCreateTrack() R.id.action_open_location -> onClickOpenLocationInOtherApp(position) } true @@ -728,6 +746,20 @@ class MainFragment : showInBottomSheet(CreateNoteFragment()) } + private fun onClickCreateTrack() { + // Check that the user has required permission to record a track + val hasUploadPermission = prefs.getBoolean(Prefs.OSM_HAS_UPLOAD_TRACES_PERMISSION, false) + if (!hasUploadPermission) { + RequestPermissionUpgradeDialog(requireContext(), get()).show() + return + } + + // Else we are good to start recording! + val mapFragment = mapFragment ?: return + mapFragment.startPositionTrackRecording() + binding.stopTracksButton.isVisible = true + } + // ---------------------------------- Location Pointer Pin --------------------------------- */ private fun updateLocationPointerPin() { diff --git a/app/src/main/java/de/westnordost/streetcomplete/screens/main/map/LocationAwareMapFragment.kt b/app/src/main/java/de/westnordost/streetcomplete/screens/main/map/LocationAwareMapFragment.kt index d69a41f0a77..885788afc72 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/screens/main/map/LocationAwareMapFragment.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/screens/main/map/LocationAwareMapFragment.kt @@ -10,6 +10,7 @@ import android.view.WindowManager import androidx.core.content.edit import androidx.core.content.getSystemService import de.westnordost.streetcomplete.data.osm.mapdata.LatLon +import de.westnordost.streetcomplete.data.osmtracks.Trackpoint import de.westnordost.streetcomplete.screens.main.map.components.CurrentLocationMapComponent import de.westnordost.streetcomplete.screens.main.map.components.TracksMapComponent import de.westnordost.streetcomplete.screens.main.map.tangram.screenBottomToCenterDistance @@ -44,6 +45,15 @@ open class LocationAwareMapFragment : MapFragment() { /** The GPS trackpoints the user has walked */ private var tracks: MutableList> + /** If we are actively recording track history */ + var isRecordingTracks = false + private set + + /** The GPS trackpoints the user has recorded */ + private var _recordedTracks: ArrayList + + val recordedTracks: List get() = _recordedTracks + /** Whether the view should automatically center on the GPS location */ var isFollowingPosition = true set(value) { @@ -71,6 +81,7 @@ open class LocationAwareMapFragment : MapFragment() { /** Called after the map fragment updated its displayed location */ fun onDisplayedLocationDidChange() } + private val listener: Listener? get() = parentFragment as? Listener ?: activity as? Listener /* ------------------------------------ Lifecycle ------------------------------------------- */ @@ -78,6 +89,7 @@ open class LocationAwareMapFragment : MapFragment() { init { tracks = ArrayList() tracks.add(ArrayList()) + _recordedTracks = ArrayList() } override fun onAttach(context: Context) { @@ -93,10 +105,22 @@ open class LocationAwareMapFragment : MapFragment() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - displayedLocation = savedInstanceState?.getParcelable(DISPLAYED_LOCATION) - val nullTerminatedTracks = savedInstanceState?.getParcelableArrayList(TRACKS) as ArrayList? - if (nullTerminatedTracks != null) { - tracks = nullTerminatedTracks.unflattenNullTerminated() + // Restore value of members from saved state + if (savedInstanceState != null) { + with(savedInstanceState) { + displayedLocation = getParcelable(DISPLAYED_LOCATION) + isRecordingTracks = getBoolean(TRACKS_IS_RECORDING) + val nullTerminatedTracks = + getParcelableArrayList(TRACKS) as ArrayList? + if (nullTerminatedTracks != null) { + tracks = nullTerminatedTracks.unflattenNullTerminated() + // unflattenNullTerminated creates an empty list item (i.e. a new track) at the end. + // This is fine if the track is not being recorded. + if (isRecordingTracks) { + tracks.removeLastOrNull() + } + } + } } } @@ -125,7 +149,7 @@ open class LocationAwareMapFragment : MapFragment() { locationMapComponent?.location = displayedLocation tracksMapComponent = TracksMapComponent(ctrl) - tracksMapComponent?.setTracks(tracks) + tracksMapComponent?.setTracks(tracks, isRecordingTracks) centerCurrentPositionIfFollowing() } @@ -159,6 +183,33 @@ open class LocationAwareMapFragment : MapFragment() { tracksMapComponent?.clear() } + @SuppressLint("MissingPermission") + fun startPositionTrackRecording() { + isRecordingTracks = true + _recordedTracks.clear() + tracks.add(ArrayList()) + locationMapComponent?.isVisible = true + locationManager.requestUpdates(500, 1f) + tracksMapComponent?.startNewTrack(true) + } + + fun stopPositionTrackRecording() { + isRecordingTracks = false + _recordedTracks.clear() + tracks.last().forEach { + _recordedTracks.add( + Trackpoint( + LatLon(it.latitude, it.longitude), + it.time, // in milliseconds + it.accuracy, + it.altitude.toFloat() // always zero in emulator: https://stackoverflow.com/q/65325665 + ) + ) + } + tracks.add(ArrayList()) + tracksMapComponent?.startNewTrack(false) + } + protected open fun shouldCenterCurrentPosition(): Boolean { return isFollowingPosition } @@ -217,10 +268,10 @@ open class LocationAwareMapFragment : MapFragment() { val lastLocation = tracks.last().lastOrNull() // create new track if last position too old - if (lastLocation != null) { + if (lastLocation != null && !isRecordingTracks) { if ((displayedLocation?.time ?: 0) - lastLocation.time > MAX_TIME_BETWEEN_LOCATIONS) { tracks.add(ArrayList()) - tracksMapComponent?.startNewTrack() + tracksMapComponent?.startNewTrack(false) } } @@ -261,6 +312,7 @@ open class LocationAwareMapFragment : MapFragment() { super.onSaveInstanceState(outState) outState.putParcelable(DISPLAYED_LOCATION, displayedLocation) outState.putParcelableArrayList(TRACKS, tracks.flattenToNullTerminated()) + outState.putBoolean(TRACKS_IS_RECORDING, isRecordingTracks) } companion object { @@ -269,6 +321,7 @@ open class LocationAwareMapFragment : MapFragment() { private const val DISPLAYED_LOCATION = "displayed_location" private const val TRACKS = "tracks" + private const val TRACKS_IS_RECORDING = "tracks_is_recording" private const val MIN_TRACK_ACCURACY = 20f private const val MAX_TIME_BETWEEN_LOCATIONS = 60L * 1000 // 1 minute diff --git a/app/src/main/java/de/westnordost/streetcomplete/screens/main/map/components/TracksMapComponent.kt b/app/src/main/java/de/westnordost/streetcomplete/screens/main/map/components/TracksMapComponent.kt index 8fd08e63717..705fa08a437 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/screens/main/map/components/TracksMapComponent.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/screens/main/map/components/TracksMapComponent.kt @@ -21,47 +21,50 @@ class TracksMapComponent(ctrl: KtMapController) { private val layer2 = ctrl.addDataLayer(LAYER2) private var index = 0 - private var tracks: MutableList> - - init { - tracks = ArrayList() - tracks.add(ArrayList()) - } + private data class Track(val trackpoints: MutableList, val isRecording: Boolean) + private var tracks: MutableList = arrayListOf(Track(ArrayList(), false)) /** Add a point to the current track */ fun addToCurrentTrack(pos: Location) { val track = tracks.last() - track.add(pos.toLngLat()) + track.trackpoints.add(pos.toLngLat()) + val trackpoints = track.trackpoints // every 100th trackpoint, move the index to the back - if (track.size - index > 100) { + if (trackpoints.size - index > 100) { putAllTracksInOldLayer() } else { - layer1.setFeatures(listOf(track.subList(index, track.size).toPolyline(false))) + layer1.setFeatures(listOf(trackpoints.subList(index, trackpoints.size).toPolyline(false, track.isRecording))) } } /** Start a new track. I.e. the points in that track will be drawn as an own polyline */ - fun startNewTrack() { - tracks.add(ArrayList()) + fun startNewTrack(record: Boolean) { + tracks.add(Track(ArrayList(), record)) putAllTracksInOldLayer() } - /** Set all the tracks (when re-initializing) */ - fun setTracks(tracks: List>) { - this.tracks = tracks.map { track -> track.map { it.toLngLat() }.toMutableList() }.toMutableList() + /** Set all the tracks (when re-initializing), if recording the last track is the only recording */ + fun setTracks(tracks: List>, isRecording: Boolean) { + this.tracks = tracks.mapIndexed { it, track -> + var recording = false + if (isRecording && it == tracks.size - 1) { + recording = true + } + Track(track.map { it.toLngLat() }.toMutableList(), recording) + }.toMutableList() putAllTracksInOldLayer() } private fun putAllTracksInOldLayer() { - index = max(0, tracks.last().lastIndex) + index = max(0, tracks.last().trackpoints.lastIndex) layer1.clear() - layer2.setFeatures(tracks.map { it.toPolyline(true) }) + layer2.setFeatures(tracks.map { it.trackpoints.toPolyline(true, it.isRecording) }) } fun clear() { tracks = ArrayList() - startNewTrack() + startNewTrack(false) } companion object { @@ -71,5 +74,5 @@ class TracksMapComponent(ctrl: KtMapController) { } } -private fun List.toPolyline(old: Boolean) = - Polyline(this, mapOf("type" to "line", "old" to old.toString())) +private fun List.toPolyline(old: Boolean, record: Boolean) = + Polyline(this, mapOf("type" to "line", "old" to old.toString(), "record" to record.toString())) diff --git a/app/src/main/java/de/westnordost/streetcomplete/screens/user/login/LoginFragment.kt b/app/src/main/java/de/westnordost/streetcomplete/screens/user/login/LoginFragment.kt index 7690e4bf320..5c6f71d87a7 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/screens/user/login/LoginFragment.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/screens/user/login/LoginFragment.kt @@ -137,7 +137,9 @@ class LoginFragment : private val REQUIRED_OSM_PERMISSIONS = listOf( Permission.READ_PREFERENCES_AND_USER_DETAILS, Permission.MODIFY_MAP, - Permission.WRITE_NOTES + Permission.WRITE_NOTES, + Permission.READ_GPS_TRACES, + Permission.WRITE_GPS_TRACES, ) private const val ARG_LAUNCH_AUTH = "launch_auth" diff --git a/app/src/main/java/de/westnordost/streetcomplete/view/dialogs/RequestPermissionUpgradeDialog.kt b/app/src/main/java/de/westnordost/streetcomplete/view/dialogs/RequestPermissionUpgradeDialog.kt new file mode 100644 index 00000000000..75b6c9e44be --- /dev/null +++ b/app/src/main/java/de/westnordost/streetcomplete/view/dialogs/RequestPermissionUpgradeDialog.kt @@ -0,0 +1,30 @@ +package de.westnordost.streetcomplete.view.dialogs + +import android.content.Context +import android.content.Intent +import android.view.LayoutInflater +import androidx.appcompat.app.AlertDialog +import de.westnordost.streetcomplete.R +import de.westnordost.streetcomplete.data.user.UserLoginStatusController +import de.westnordost.streetcomplete.screens.user.UserActivity + +/** Shows a dialog that asks the user to login */ +class RequestPermissionUpgradeDialog( + context: Context, + private val userLoginStatusController: UserLoginStatusController, +) : AlertDialog(context, R.style.Theme_Bubble_Dialog) { + + init { + val view = LayoutInflater.from(context).inflate(R.layout.dialog_permission_upgrade, null, false) + setView(view) + setButton(BUTTON_POSITIVE, context.getString(android.R.string.ok)) { _, _ -> + if (userLoginStatusController.isLoggedIn) { + userLoginStatusController.logOut() + } + val intent = Intent(context, UserActivity::class.java) + intent.putExtra(UserActivity.EXTRA_LAUNCH_AUTH, true) + context.startActivity(intent) + } + setButton(BUTTON_NEGATIVE, context.getString(R.string.later)) { _, _ -> } + } +} diff --git a/app/src/main/res/layout/dialog_permission_upgrade.xml b/app/src/main/res/layout/dialog_permission_upgrade.xml new file mode 100644 index 00000000000..d681ddc176b --- /dev/null +++ b/app/src/main/res/layout/dialog_permission_upgrade.xml @@ -0,0 +1,14 @@ + + + + + + diff --git a/app/src/main/res/layout/fragment_main.xml b/app/src/main/res/layout/fragment_main.xml index 94d44eebcc0..40ed2d8b6df 100644 --- a/app/src/main/res/layout/fragment_main.xml +++ b/app/src/main/res/layout/fragment_main.xml @@ -86,6 +86,16 @@ android:clipToPadding="false" tools:ignore="RtlHardcoded,RtlSymmetry"> + + + + android:orderInCategory="3" /> diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index adae119619a..184a1e0593f 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -60,6 +60,9 @@ The info you enter is then directly added to the OpenStreetMap in your name, wit You can leave a note to let other mappers know about an issue here. Adjust the note’s position by moving the map: Ideally, write the note in the local language, or otherwise in English. + Create new track recording + "Stop Recording Track" + @@ -228,6 +231,7 @@ If you are overwhelmed by the number of quests, you can always fine-tune which q "You need to authorize with your OSM user account to publish your answers. Authorize now?" You can also do this later on the profile screen. + "You need to authorize this app to publish track recordings on OpenStreetMap. This requires to log in again. Proceed?" "Later" "Not authorized" @@ -511,7 +515,6 @@ However, before uploading your changes, the app checks with a <a href=\"https Quest selection and display order %1$d of %2$d enabled Preset: %s - Delete Delete all downloaded map data, including quests?\nData is refreshed after %1$s days and unused data is deleted after %2$s days automatically.