Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
xcuserdata
!src/**/build/
local.properties
StravaClientSecret.kt
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Here I added StravaClientSecret.kt to the .gitignore so we never commit that file so others can see it 😬

.idea
.DS_Store
captures
Expand Down
9 changes: 6 additions & 3 deletions composeApp/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ kotlin {
androidMain.dependencies {
implementation(compose.preview)
implementation(libs.androidx.activity.compose)
implementation(libs.ktor.client.okhttp)
}
commonMain.dependencies {
implementation(compose.runtime)
Expand All @@ -41,11 +42,13 @@ kotlin {
implementation(compose.components.uiToolingPreview)
implementation(libs.androidx.lifecycle.viewmodelCompose)
implementation(libs.androidx.lifecycle.runtimeCompose)

implementation(libs.jetbrains.navigation3.ui)
implementation(libs.kotlinx.serialization.json)

implementation("io.ktor:ktor-http:3.4.1")
implementation(libs.kotlinx.datetime)
implementation(libs.ktor.client.core)
implementation(libs.ktor.client.content.negotiation)
implementation(libs.ktor.serialization.kotlinx.json)
implementation(libs.ktor.http)
}
commonTest.dependencies {
implementation(libs.kotlin.test)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ class MainActivity : ComponentActivity() {
MaterialTheme {
NavigationRoot(
urlOpener = AndroidUrlOpener(context = this),
stravaAccessToken = accessToken,
)
}
}
Expand Down
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) {
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The 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
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The 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.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The 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 @GET("url")


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(
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Dto stands for data transfer object. The idea is that this lives in the data layer and just helps us work with the "response" that we get from network requests.

@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
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The 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 StravaApi and the application. It's kind of redundant with respect to the UseCase we have.

) {
suspend fun getAccessToken(
code: String,
): StravaAuthenticationDto {
return api.getAccessToken(
clientId = StravaClientSecret.STRAVA_CLIENT_ID,
clientSecret = StravaClientSecret.STRAVA_CLIENT_SECRET,
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The 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 com.majotyler.hiittimer.network.StravaClientSecret in commonMain and I will send you what you should put in there (the STRAVA_CLIENT_SECRET).

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The 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

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The 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
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The 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.

It is super useful!
Image

Image

Here, I can see both of my network requests in this CreateStravaActivityUseCase: first I get the access token, and then I make the Activity on Strava.

I can also see the "response" code. It is 200 and 201 :) Both of which mean success, 201 means something new was made, which is correct: we made an activity.

}
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
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The 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
Expand Up @@ -33,10 +33,12 @@ import kotlin.uuid.Uuid
@Composable
fun NavigationRoot(
urlOpener: UrlOpener,
stravaAccessToken: String?,
) {
HiitAppTheme {
HiitNavDisplay(
urlOpener = urlOpener,
stravaAccessToken = stravaAccessToken,
)
}
}
Expand All @@ -45,6 +47,7 @@ fun NavigationRoot(
@Composable
private fun HiitNavDisplay(
urlOpener: UrlOpener,
stravaAccessToken: String?,
) {

val resultBus = remember { ResultEventBus() }
Expand Down Expand Up @@ -102,7 +105,8 @@ private fun HiitNavDisplay(
backStack.add(element = Route.BuildWorkouts)
}
}
}
},
stravaAccessToken = stravaAccessToken,
)

HomeScreen(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The 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
Expand Up @@ -2,5 +2,8 @@ package com.majotyler.hiittimer.presentation.homeScreen

sealed interface HomeViewEvent {
data object ClickedConnectWithStrava : HomeViewEvent

// TODO This is temporary just to demonstrate
data object ClickedCreateStravaActivity : HomeViewEvent
data object ClickedLaunchBuildWorkouts : HomeViewEvent
}
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
Copy link
Copy Markdown
Owner Author

@Tyler-Lopez Tyler-Lopez Mar 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is all bad practice - how I get createStravaActivityUseCase in the ViewModel, and in the future we will use dependency injection here. I'm just not quite sure how to implement that yet. You'll see how it is much better in the future :)


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)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,12 @@ import kotlin.reflect.KClass

class HomeViewModelFactory(
private val router: (HomeDestination) -> Unit,
private val stravaAccessToken: String?,
) : ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: KClass<T>, extras: CreationExtras): T {
return HomeViewModel(
router = router,
stravaAccessCode = stravaAccessToken,
) as T
}
}
8 changes: 8 additions & 0 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ androidx-testExt = "1.3.0"
composeMultiplatform = "1.9.0"
junit = "4.13.2"
kotlin = "2.2.20"
kotlinx-datetime = "0.6.2"
ktor = "3.4.1"
navigation3 = "1.0.0"
cmpNavigation3 = "1.0.0-alpha05"

Expand All @@ -28,7 +30,13 @@ androidx-lifecycle-viewmodelCompose = { module = "org.jetbrains.androidx.lifecyc
androidx-lifecycle-runtimeCompose = { module = "org.jetbrains.androidx.lifecycle:lifecycle-runtime-compose", version.ref = "androidx-lifecycle" }
jetbrains-navigation3-ui = { module = "org.jetbrains.androidx.navigation3:navigation3-ui", version.ref = "cmpNavigation3" }
jetbrains-lifecycle-viewmodel = { module = "org.jetbrains.androidx.lifecycle:lifecycle-viewmodel", version.ref = "androidx-lifecycle"}
kotlinx-datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.ref = "kotlinx-datetime" }
kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version = "1.9.0" }
ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" }
ktor-client-okhttp = { module = "io.ktor:ktor-client-okhttp", version.ref = "ktor" }
ktor-client-content-negotiation = { module = "io.ktor:ktor-client-content-negotiation", version.ref = "ktor" }
ktor-serialization-kotlinx-json = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" }
ktor-http = { module = "io.ktor:ktor-http", version.ref = "ktor" }

[plugins]
androidApplication = { id = "com.android.application", version.ref = "agp" }
Expand Down