-
Notifications
You must be signed in to change notification settings - Fork 0
Create Strava Activity from App #38
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -5,6 +5,7 @@ | |
| xcuserdata | ||
| !src/**/build/ | ||
| local.properties | ||
| StravaClientSecret.kt | ||
| .idea | ||
| .DS_Store | ||
| captures | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,59 @@ | ||
| package com.majotyler.hiittimer.data.api | ||
|
|
||
| import com.majotyler.hiittimer.data.dto.StravaAuthenticationDto | ||
| import io.ktor.client.HttpClient | ||
| import io.ktor.client.call.body | ||
| import io.ktor.client.request.forms.FormDataContent | ||
| import io.ktor.client.request.header | ||
| import io.ktor.client.request.parameter | ||
| import io.ktor.client.request.post | ||
| import io.ktor.client.request.setBody | ||
| import io.ktor.http.HttpHeaders | ||
| import io.ktor.http.Parameters | ||
|
|
||
| class StravaApi(private val client: HttpClient) { | ||
|
Owner
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Check out this page: https://developers.strava.com/docs/reference/#api-Activities-createActivity :) That lists everything we can do with the Strava API. All I added here was just something to get the access token and also to "Create an Activity". |
||
| suspend fun getAccessToken( | ||
| clientId: Int, | ||
| clientSecret: String, | ||
| code: String, | ||
| ): StravaAuthenticationDto { | ||
| return client.post(urlString = StravaEndpoints.OAUTH_TOKEN) { | ||
| parameter(key = "client_id", value = clientId) | ||
| parameter(key = "client_secret", value = clientSecret) | ||
| parameter(key = "code", value = code) | ||
| parameter(key = "grant_type", value = StravaGrantTypes.AUTH_CODE) | ||
| }.body() | ||
| } | ||
|
Comment on lines
+20
to
+26
Owner
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is Ktor. It is a networking library. Another library is Retrofit, it is very popular but only works with Android. As this is a Kotlin Multiplatform project, we use Ktor instead.
Owner
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Here is what Retrofit looks like in my other application that uses Strava https://github.com/Tyler-Lopez/StravaActivityArt/blob/main/app/src/main/java/com/activityartapp/data/remote/AthleteApi.kt You can see it is "annotation-based" like |
||
|
|
||
| suspend fun createActivity( | ||
| accessToken: String, | ||
| name: String, | ||
| type: String, | ||
| startDateLocal: String, | ||
| elapsedTime: Int, | ||
| ) { | ||
| return client.post(urlString = StravaEndpoints.ACTIVITIES) { | ||
| header(key = HttpHeaders.Authorization, value = "Bearer $accessToken") | ||
| setBody( | ||
| body = FormDataContent( | ||
| formData = Parameters.build { | ||
| append(name = "name", value = name) | ||
| append(name = "sport_type", value = type) | ||
| append(name = "start_date_local", value = startDateLocal) | ||
| append(name = "elapsed_time", value = elapsedTime.toString()) | ||
| } | ||
| ) | ||
| ) | ||
| }.body() | ||
| } | ||
| } | ||
|
|
||
| private object StravaEndpoints { | ||
| const val BASE_URL = "https://www.strava.com" | ||
| const val OAUTH_TOKEN = "$BASE_URL/oauth/token" | ||
| const val ACTIVITIES = "$BASE_URL/api/v3/activities" | ||
| } | ||
|
|
||
| private object StravaGrantTypes { | ||
| const val AUTH_CODE = "authorization_code" | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,18 @@ | ||
| package com.majotyler.hiittimer.data.dto | ||
|
|
||
| import kotlinx.serialization.SerialName | ||
| import kotlinx.serialization.Serializable | ||
|
|
||
| @Serializable | ||
| data class StravaAuthenticationDto( | ||
|
Owner
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
| @SerialName("token_type") | ||
| val tokenType: String, | ||
| @SerialName("expires_at") | ||
| val expiresAt: Long, | ||
| @SerialName("expires_in") | ||
| val expiresIn: Int, | ||
| @SerialName("refresh_token") | ||
| val refreshToken: String, | ||
| @SerialName("access_token") | ||
| val accessToken: String, | ||
| ) | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,29 @@ | ||
| package com.majotyler.hiittimer.data.repository | ||
|
|
||
| import com.majotyler.hiittimer.data.api.StravaApi | ||
| import com.majotyler.hiittimer.data.dto.StravaAuthenticationDto | ||
| import com.majotyler.hiittimer.network.StravaClientSecret | ||
|
|
||
| class StravaRepository( | ||
| private val api: StravaApi | ||
|
Comment on lines
+7
to
+8
Owner
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The reason we use "repository" is part of clean architecture. The idea is that a repository is a middle-layer between the data source |
||
| ) { | ||
| suspend fun getAccessToken( | ||
| code: String, | ||
| ): StravaAuthenticationDto { | ||
| return api.getAccessToken( | ||
| clientId = StravaClientSecret.STRAVA_CLIENT_ID, | ||
| clientSecret = StravaClientSecret.STRAVA_CLIENT_SECRET, | ||
|
Owner
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. These Constants are in a file in the repository that is not added to GitHub. It is because a client secret is very dangerous information to have in a public GitHub. There are much better ways of doing this, and I'll try to improve it in the future. For now, in order to make the app work after this PR, you will need to make a file at
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yeah sure. I’ll create the StravaClientSecret file in commonMain. Also, Do you have any recommendations or resources on how to manage the security of an app? I'd love to learn more
Owner
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I haven't had to deal too much with this before, but it is a really big topic that would be very valuable for us to learn more about. One problem with what I am doing is that it is possible for people to "reverse-engineer" applications. That means people that have applications on their phone can find ways to get the code of an application. If our client secret is in the code, like it is here (even though not on GitHub) that is a problem :X One solution, I think, is to create our own server and then use the server to give "clients" the client secret, rather than it being a part of the code. One other problem with putting the client secret in code is that, if this ever is "compromised" (stolen, found-out) then we need to basically tell Strava that our client secret got compromised and we need a new one. That would make all the old versions of the application broken forever because the hard-coded client secret is now not updated. Does that make sense? |
||
| code = code, | ||
| ) | ||
| } | ||
|
|
||
| suspend fun createActivity( | ||
| accessToken: String, | ||
| name: String, | ||
| type: String, | ||
| startDateLocal: String, | ||
| elapsedTime: Int, | ||
| ) { | ||
| return api.createActivity(accessToken, name, type, startDateLocal, elapsedTime) | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,33 @@ | ||
| package com.majotyler.hiittimer.domain.usecase | ||
|
|
||
| import com.majotyler.hiittimer.data.repository.StravaRepository | ||
| import com.majotyler.hiittimer.network.StravaClientSecret | ||
| import kotlinx.datetime.Clock | ||
| import kotlinx.datetime.TimeZone | ||
| import kotlinx.datetime.toLocalDateTime | ||
| import kotlin.time.Duration.Companion.hours | ||
| import kotlin.time.ExperimentalTime | ||
|
|
||
| class CreateStravaActivityUseCase( | ||
| private val stravaAccessCode: String, | ||
| private val repository: StravaRepository, | ||
| ) { | ||
| @OptIn(ExperimentalTime::class) | ||
| suspend operator fun invoke() { | ||
| val accessToken = repository.getAccessToken(code = stravaAccessCode) | ||
|
|
||
| repository.createActivity( | ||
| accessToken = accessToken.accessToken, | ||
| name = "Created from HIIT App", | ||
| type = "HighIntensityIntervalTraining", | ||
| startDateLocal = TimeZone.currentSystemDefault() | ||
| .let { tz -> | ||
| Clock.System.now() | ||
| .minus(1.hours) | ||
| .toLocalDateTime(tz) | ||
| .toString() | ||
| }, | ||
| elapsedTime = 60, | ||
| ) | ||
| } | ||
|
Comment on lines
+15
to
+32
Owner
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. In Android Studio, you can see every network request that your application makes: the request and the response in something called the Network Inspector.
Here, I can see both of my network requests in this I can also see the "response" code. It is |
||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,21 @@ | ||
| package com.majotyler.hiittimer.network | ||
|
|
||
| import io.ktor.client.* | ||
| import io.ktor.client.plugins.contentnegotiation.* | ||
| import io.ktor.serialization.kotlinx.json.* | ||
| import kotlinx.serialization.json.Json | ||
|
|
||
| object HttpClientFactory { | ||
|
|
||
| fun create(): HttpClient { | ||
| return HttpClient { | ||
| install(ContentNegotiation) { | ||
| json( | ||
| Json { | ||
| ignoreUnknownKeys = true | ||
| } | ||
| ) | ||
| } | ||
| } | ||
| } | ||
| } | ||
|
Comment on lines
+8
to
+21
Owner
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is just boiler plate, and I should probably re-write this to be better. I would ignore this in terms of your learning. |
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -44,5 +44,15 @@ fun HomeScreen( | |
| ) { | ||
| Text(text = "Connect with Strava") | ||
| } | ||
|
|
||
| if (viewModel.showCreateActivityButton) { | ||
| Button( | ||
| onClick = { | ||
| viewModel.onEvent(event = HomeViewEvent.ClickedCreateStravaActivity) | ||
| } | ||
| ) { | ||
| Text(text = "Create Strava Activity") | ||
| } | ||
| } | ||
|
Comment on lines
+48
to
+56
Owner
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Just temporary that this is here. When you click the Button it makes a Strava Activity that happened an hour ago and lasted 60 seconds. We'll move this later. |
||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,34 +1,67 @@ | ||
| package com.majotyler.hiittimer.presentation.homeScreen | ||
|
|
||
| import androidx.compose.ui.text.input.KeyboardType.Companion.Uri | ||
| import androidx.lifecycle.ViewModel | ||
| import io.ktor.http.* | ||
| import androidx.lifecycle.viewModelScope | ||
| import com.majotyler.hiittimer.data.api.StravaApi | ||
| import com.majotyler.hiittimer.data.repository.StravaRepository | ||
| import com.majotyler.hiittimer.domain.usecase.CreateStravaActivityUseCase | ||
| import com.majotyler.hiittimer.network.HttpClientFactory | ||
| import com.majotyler.hiittimer.presentation.common.navigation.Router | ||
| import kotlinx.coroutines.flow.MutableSharedFlow | ||
| import kotlinx.coroutines.flow.asSharedFlow | ||
| import kotlinx.coroutines.launch | ||
|
|
||
| class HomeViewModel( | ||
| private val router: Router<HomeDestination>, | ||
| private val stravaAccessCode: String?, | ||
| ) : ViewModel() { | ||
|
|
||
| private val _openUrl = MutableSharedFlow<String>() | ||
| val openUrl = _openUrl.asSharedFlow() | ||
|
|
||
| // TODO This is temporary to show something to create a Strava Activity | ||
| val showCreateActivityButton = stravaAccessCode != null | ||
|
|
||
| private val httpClient by lazy { | ||
| HttpClientFactory.create() | ||
| } | ||
|
|
||
| private val stravaApi: StravaApi by lazy { | ||
| StravaApi(httpClient) | ||
| } | ||
|
|
||
| private val stravaRepository by lazy { | ||
| StravaRepository(stravaApi) | ||
| } | ||
| private val createStravaActivityUseCase by lazy { | ||
| CreateStravaActivityUseCase( | ||
| stravaAccessCode = stravaAccessCode ?: "", | ||
| repository = stravaRepository, | ||
| ) | ||
| } | ||
|
Comment on lines
+26
to
+42
Owner
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is all bad practice - how I get |
||
|
|
||
| fun onEvent(event: HomeViewEvent) { | ||
| when (event) { | ||
| is HomeViewEvent.ClickedConnectWithStrava -> onClickedConnectWithStrava() | ||
| is HomeViewEvent.ClickedCreateStravaActivity -> onClickedCreateStravaActivity() | ||
| is HomeViewEvent.ClickedLaunchBuildWorkouts -> onClickedLaunchBuildWorkouts() | ||
| } | ||
| } | ||
|
|
||
| private fun onClickedConnectWithStrava() { | ||
|
|
||
| viewModelScope.launch { | ||
| _openUrl.emit(value = getAuthUri()) | ||
| } | ||
| } | ||
|
|
||
| private fun onClickedCreateStravaActivity() { | ||
| viewModelScope.launch { | ||
| createStravaActivityUseCase() | ||
| } | ||
| } | ||
|
|
||
| private fun onClickedLaunchBuildWorkouts() { | ||
| router.routeTo(destination = HomeDestination.NavigateToBuildWorkouts) | ||
| } | ||
|
|
||


There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Here I added
StravaClientSecret.ktto the.gitignoreso we never commit that file so others can see it 😬