Merge pull request #87 from SELab1/timerfix

Timerfix
This commit is contained in:
lbarraga 2023-04-30 12:23:47 +02:00 committed by GitHub Enterprise
commit 6b7ec41f32
23 changed files with 207 additions and 57 deletions

View file

@ -42,5 +42,6 @@
<option name="processLiterals" value="true" /> <option name="processLiterals" value="true" />
<option name="processComments" value="true" /> <option name="processComments" value="true" />
</inspection_tool> </inspection_tool>
<inspection_tool class="TestFunctionName" enabled="false" level="WEAK WARNING" enabled_by_default="false" />
</profile> </profile>
</component> </component>

2
.idea/misc.xml generated
View file

@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<project version="4"> <project version="4">
<component name="ExternalStorageConfigurationManager" enabled="true" /> <component name="ExternalStorageConfigurationManager" enabled="true" />
<component name="ProjectRootManager" version="2" languageLevel="JDK_17_PREVIEW" project-jdk-name="jbr-17" project-jdk-type="JavaSDK"> <component name="ProjectRootManager" version="2" languageLevel="JDK_17" default="true" project-jdk-name="jbr-17" project-jdk-type="JavaSDK">
<output url="file://$PROJECT_DIR$/build/classes" /> <output url="file://$PROJECT_DIR$/build/classes" />
</component> </component>
<component name="ProjectType"> <component name="ProjectType">

View file

@ -66,6 +66,7 @@ dependencies {
implementation "androidx.compose.ui:ui-tooling-preview:$compose_version" implementation "androidx.compose.ui:ui-tooling-preview:$compose_version"
implementation 'androidx.compose.material:material:1.2.0' implementation 'androidx.compose.material:material:1.2.0'
debugImplementation "androidx.compose.ui:ui-test-manifest:$compose_version" debugImplementation "androidx.compose.ui:ui-test-manifest:$compose_version"
// ViewModel // ViewModel
@ -97,6 +98,9 @@ dependencies {
testImplementation 'junit:junit:4.13.2' testImplementation 'junit:junit:4.13.2'
androidTestImplementation 'androidx.test.ext:junit:1.1.5' androidTestImplementation 'androidx.test.ext:junit:1.1.5'
// Coroutine testing
testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.6.4'
// Mocking // Mocking
testImplementation 'org.mockito.kotlin:mockito-kotlin:3.2.0' testImplementation 'org.mockito.kotlin:mockito-kotlin:3.2.0'

View file

@ -10,9 +10,15 @@ import androidx.compose.material.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.lifecycle.lifecycleScope
import be.ugent.sel.studeez.StudeezApp import be.ugent.sel.studeez.StudeezApp
import be.ugent.sel.studeez.screens.session.InvisibleSessionManager
import be.ugent.sel.studeez.ui.theme.StudeezTheme import be.ugent.sel.studeez.ui.theme.StudeezTheme
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
var onTimerInvisible: Job? = null
@AndroidEntryPoint @AndroidEntryPoint
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
@ -30,6 +36,18 @@ class MainActivity : ComponentActivity() {
} }
} }
} }
override fun onStop() {
onTimerInvisible = lifecycleScope.launch {
InvisibleSessionManager.updateTimer()
}
super.onStop()
}
override fun onStart() {
onTimerInvisible?.cancel()
super.onStart()
}
} }
@Composable @Composable

View file

@ -1,6 +1,5 @@
package be.ugent.sel.studeez.common.composable.navbar package be.ugent.sel.studeez.common.composable.navbar
import android.util.Log
import androidx.compose.material.BottomNavigation import androidx.compose.material.BottomNavigation
import androidx.compose.material.BottomNavigationItem import androidx.compose.material.BottomNavigationItem
import androidx.compose.material.Icon import androidx.compose.material.Icon

View file

@ -1,9 +1,5 @@
package be.ugent.sel.studeez.data.local.models.timer_functional package be.ugent.sel.studeez.data.local.models.timer_functional
import be.ugent.sel.studeez.data.local.models.SessionReport
import be.ugent.sel.studeez.screens.session.sessionScreens.CustomSessionScreen
import be.ugent.sel.studeez.screens.session.sessionScreens.AbstractSessionScreen
class FunctionalCustomTimer(studyTime: Int) : FunctionalTimer(studyTime) { class FunctionalCustomTimer(studyTime: Int) : FunctionalTimer(studyTime) {
override fun tick() { override fun tick() {

View file

@ -1,8 +1,5 @@
package be.ugent.sel.studeez.data.local.models.timer_functional package be.ugent.sel.studeez.data.local.models.timer_functional
import be.ugent.sel.studeez.screens.session.sessionScreens.BreakSessionScreen
import be.ugent.sel.studeez.screens.session.sessionScreens.AbstractSessionScreen
class FunctionalPomodoroTimer( class FunctionalPomodoroTimer(
private var studyTime: Int, private var studyTime: Int,
private var breakTime: Int, repeats: Int private var breakTime: Int, repeats: Int

View file

@ -1,7 +1,6 @@
package be.ugent.sel.studeez.data.local.models.timer_functional package be.ugent.sel.studeez.data.local.models.timer_functional
import be.ugent.sel.studeez.data.local.models.SessionReport import be.ugent.sel.studeez.data.local.models.SessionReport
import be.ugent.sel.studeez.screens.session.sessionScreens.AbstractSessionScreen
import com.google.firebase.Timestamp import com.google.firebase.Timestamp
abstract class FunctionalTimer(initialValue: Int) { abstract class FunctionalTimer(initialValue: Int) {

View file

@ -5,7 +5,6 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.hilt.navigation.compose.hiltViewModel
import be.ugent.sel.studeez.R import be.ugent.sel.studeez.R
import be.ugent.sel.studeez.common.composable.BasicTextButton import be.ugent.sel.studeez.common.composable.BasicTextButton
import be.ugent.sel.studeez.common.composable.LabelledInputField import be.ugent.sel.studeez.common.composable.LabelledInputField

View file

@ -0,0 +1,29 @@
package be.ugent.sel.studeez.screens.session
import android.media.MediaPlayer
import kotlinx.coroutines.delay
import javax.inject.Singleton
import kotlin.time.Duration.Companion.seconds
@Singleton
object InvisibleSessionManager {
private var viewModel: SessionViewModel? = null
private lateinit var mediaPlayer: MediaPlayer
fun setParameters(viewModel: SessionViewModel, mediaplayer: MediaPlayer) {
this.viewModel = viewModel
this.mediaPlayer = mediaplayer
}
suspend fun updateTimer() {
viewModel?.let {
while (!it.getTimer().hasEnded()) {
delay(1.seconds)
it.getTimer().tick()
if (it.getTimer().hasCurrentCountdownEnded()) {
mediaPlayer.start()
}
}
}
}
}

View file

@ -12,7 +12,7 @@ import be.ugent.sel.studeez.screens.session.sessionScreens.GetSessionScreen
data class SessionActions( data class SessionActions(
val getTimer: () -> FunctionalTimer, val getTimer: () -> FunctionalTimer,
val getTask: () -> String, val getTask: () -> String,
val prepareMediaPlayer: () -> Unit, val startMediaPlayer: () -> Unit,
val releaseMediaPlayer: () -> Unit, val releaseMediaPlayer: () -> Unit,
val endSession: () -> Unit val endSession: () -> Unit
) )
@ -26,8 +26,8 @@ private fun getSessionActions(
getTimer = viewModel::getTimer, getTimer = viewModel::getTimer,
getTask = viewModel::getTask, getTask = viewModel::getTask,
endSession = { viewModel.endSession(openAndPopUp) }, endSession = { viewModel.endSession(openAndPopUp) },
prepareMediaPlayer = mediaplayer::prepareAsync, startMediaPlayer = mediaplayer::start,
releaseMediaPlayer = mediaplayer::release releaseMediaPlayer = mediaplayer::release,
) )
} }
@ -39,26 +39,15 @@ fun SessionRoute(
) { ) {
val context = LocalContext.current val context = LocalContext.current
val uri: Uri = RingtoneManager.getDefaultUri(RingtoneManager.TYPE_NOTIFICATION) val uri: Uri = RingtoneManager.getDefaultUri(RingtoneManager.TYPE_NOTIFICATION)
val mediaplayer = MediaPlayer() val mediaplayer = MediaPlayer.create(context, uri)
mediaplayer.setDataSource(context, uri) mediaplayer.isLooping = false
mediaplayer.setOnCompletionListener {
mediaplayer.stop()
//if (timerEnd) {
// mediaplayer.release()
//}
}
mediaplayer.setOnPreparedListener {
// mediaplayer.start()
}
val sessionScreen: AbstractSessionScreen = viewModel.getTimer().accept(GetSessionScreen()) InvisibleSessionManager.setParameters(
viewModel = viewModel,
mediaplayer = mediaplayer
)
//val sessionScreen = when (val timer = viewModel.getTimer()) { val sessionScreen: AbstractSessionScreen = viewModel.getTimer().accept(GetSessionScreen(mediaplayer))
// is FunctionalCustomTimer -> CustomSessionScreen(timer)
// is FunctionalPomodoroTimer -> BreakSessionScreen(timer)
// is FunctionalEndlessTimer -> EndlessSessionScreen()
// else -> throw java.lang.IllegalArgumentException("Unknown Timer")
//}
sessionScreen( sessionScreen(
open = open, open = open,

View file

@ -19,15 +19,12 @@ import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import be.ugent.sel.studeez.data.local.models.timer_functional.FunctionalEndlessTimer import be.ugent.sel.studeez.data.local.models.timer_functional.FunctionalEndlessTimer
import be.ugent.sel.studeez.navigation.StudeezDestinations
import be.ugent.sel.studeez.screens.session.SessionActions import be.ugent.sel.studeez.screens.session.SessionActions
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlin.time.Duration.Companion.seconds import kotlin.time.Duration.Companion.seconds
abstract class AbstractSessionScreen { abstract class AbstractSessionScreen {
var timerEnd = false
@Composable @Composable
operator fun invoke( operator fun invoke(
open: (String) -> Unit, open: (String) -> Unit,
@ -74,22 +71,10 @@ abstract class AbstractSessionScreen {
LaunchedEffect(tikker) { LaunchedEffect(tikker) {
delay(1.seconds) delay(1.seconds)
sessionActions.getTimer().tick() sessionActions.getTimer().tick()
callMediaPlayer()
tikker = !tikker tikker = !tikker
} }
if (
sessionActions.getTimer().hasCurrentCountdownEnded() && !sessionActions.getTimer()
.hasEnded()
) {
// sessionActions.prepareMediaPlayer()
}
if (!timerEnd && sessionActions.getTimer().hasEnded()) {
// sessionActions.prepareMediaPlayer()
timerEnd =
true // Placeholder, vanaf hier moet het report opgestart worden en de sessie afgesloten
}
val hms = sessionActions.getTimer().getHoursMinutesSeconds() val hms = sessionActions.getTimer().getHoursMinutesSeconds()
Column { Column {
Text( Text(
@ -136,6 +121,8 @@ abstract class AbstractSessionScreen {
@Composable @Composable
abstract fun motivationString(): String abstract fun motivationString(): String
abstract fun callMediaPlayer()
} }
@Preview @Preview
@ -144,6 +131,7 @@ fun TimerPreview() {
val sessionScreen = object : AbstractSessionScreen() { val sessionScreen = object : AbstractSessionScreen() {
@Composable @Composable
override fun motivationString(): String = "Test" override fun motivationString(): String = "Test"
override fun callMediaPlayer() {}
} }
sessionScreen.Timer(sessionActions = SessionActions({ FunctionalEndlessTimer() }, { "Preview" }, {}, {}, {})) sessionScreen.Timer(sessionActions = SessionActions({ FunctionalEndlessTimer() }, { "Preview" }, {}, {}, {}))

View file

@ -1,5 +1,6 @@
package be.ugent.sel.studeez.screens.session.sessionScreens package be.ugent.sel.studeez.screens.session.sessionScreens
import android.media.MediaPlayer
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import be.ugent.sel.studeez.R import be.ugent.sel.studeez.R
import be.ugent.sel.studeez.data.local.models.timer_functional.FunctionalPomodoroTimer import be.ugent.sel.studeez.data.local.models.timer_functional.FunctionalPomodoroTimer
@ -7,7 +8,8 @@ import be.ugent.sel.studeez.resources
import be.ugent.sel.studeez.R.string as AppText import be.ugent.sel.studeez.R.string as AppText
class BreakSessionScreen( class BreakSessionScreen(
private val funPomoDoroTimer: FunctionalPomodoroTimer private val funPomoDoroTimer: FunctionalPomodoroTimer,
private var mediaplayer: MediaPlayer?
): AbstractSessionScreen() { ): AbstractSessionScreen() {
@Composable @Composable
@ -27,4 +29,17 @@ class BreakSessionScreen(
) )
} }
override fun callMediaPlayer() {
if (funPomoDoroTimer.hasEnded()) {
mediaplayer?.let { it: MediaPlayer ->
it.setOnCompletionListener {
it.release()
mediaplayer = null
}
it.start()
}
} else if (funPomoDoroTimer.hasCurrentCountdownEnded()) {
mediaplayer?.start()
}
}
} }

View file

@ -1,5 +1,6 @@
package be.ugent.sel.studeez.screens.session.sessionScreens package be.ugent.sel.studeez.screens.session.sessionScreens
import android.media.MediaPlayer
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import be.ugent.sel.studeez.data.local.models.timer_functional.FunctionalCustomTimer import be.ugent.sel.studeez.data.local.models.timer_functional.FunctionalCustomTimer
import be.ugent.sel.studeez.resources import be.ugent.sel.studeez.resources
@ -7,7 +8,8 @@ import be.ugent.sel.studeez.R.string as AppText
class CustomSessionScreen( class CustomSessionScreen(
private val functionalTimer: FunctionalCustomTimer private val functionalTimer: FunctionalCustomTimer,
private var mediaplayer: MediaPlayer?
): AbstractSessionScreen() { ): AbstractSessionScreen() {
@Composable @Composable
@ -18,4 +20,16 @@ class CustomSessionScreen(
return resources().getString(AppText.state_focus) return resources().getString(AppText.state_focus)
} }
override fun callMediaPlayer() {
if (functionalTimer.hasEnded()) {
mediaplayer?.let { it: MediaPlayer ->
it.setOnCompletionListener {
it.release()
mediaplayer = null
}
it.start()
}
}
}
} }

View file

@ -11,4 +11,6 @@ class EndlessSessionScreen : AbstractSessionScreen() {
override fun motivationString(): String { override fun motivationString(): String {
return resources().getString(AppText.state_focus) return resources().getString(AppText.state_focus)
} }
override fun callMediaPlayer() {}
} }

View file

@ -1,17 +1,18 @@
package be.ugent.sel.studeez.screens.session.sessionScreens package be.ugent.sel.studeez.screens.session.sessionScreens
import android.media.MediaPlayer
import be.ugent.sel.studeez.data.local.models.timer_functional.FunctionalCustomTimer import be.ugent.sel.studeez.data.local.models.timer_functional.FunctionalCustomTimer
import be.ugent.sel.studeez.data.local.models.timer_functional.FunctionalEndlessTimer import be.ugent.sel.studeez.data.local.models.timer_functional.FunctionalEndlessTimer
import be.ugent.sel.studeez.data.local.models.timer_functional.FunctionalPomodoroTimer import be.ugent.sel.studeez.data.local.models.timer_functional.FunctionalPomodoroTimer
import be.ugent.sel.studeez.data.local.models.timer_functional.FunctionalTimerVisitor import be.ugent.sel.studeez.data.local.models.timer_functional.FunctionalTimerVisitor
class GetSessionScreen : FunctionalTimerVisitor<AbstractSessionScreen> { class GetSessionScreen(private val mediaplayer: MediaPlayer?) : FunctionalTimerVisitor<AbstractSessionScreen> {
override fun visitFunctionalCustomTimer(functionalCustomTimer: FunctionalCustomTimer): AbstractSessionScreen = override fun visitFunctionalCustomTimer(functionalCustomTimer: FunctionalCustomTimer): AbstractSessionScreen =
CustomSessionScreen(functionalCustomTimer) CustomSessionScreen(functionalCustomTimer, mediaplayer)
override fun visitFunctionalEndlessTimer(functionalEndlessTimer: FunctionalEndlessTimer): AbstractSessionScreen = override fun visitFunctionalEndlessTimer(functionalEndlessTimer: FunctionalEndlessTimer): AbstractSessionScreen =
EndlessSessionScreen() EndlessSessionScreen()
override fun visitFunctionalBreakTimer(functionalPomodoroTimer: FunctionalPomodoroTimer): AbstractSessionScreen = override fun visitFunctionalBreakTimer(functionalPomodoroTimer: FunctionalPomodoroTimer): AbstractSessionScreen =
BreakSessionScreen(functionalPomodoroTimer) BreakSessionScreen(functionalPomodoroTimer, mediaplayer)
} }

View file

@ -26,7 +26,7 @@ fun getSessionRecapActions(
return SessionRecapActions( return SessionRecapActions(
viewModel::getSessionReport, viewModel::getSessionReport,
{viewModel.saveSession(openAndPopUp)}, {viewModel.saveSession(openAndPopUp)},
{viewModel.saveSession(openAndPopUp)} {viewModel.discardSession(openAndPopUp)}
) )
} }

View file

@ -45,6 +45,7 @@
<!-- Sessions --> <!-- Sessions -->
<string name="sessions">Sessions</string> <string name="sessions">Sessions</string>
<string name="end_session">End session</string>
<!-- Profile --> <!-- Profile -->
<string name="profile">Profile</string> <string name="profile">Profile</string>

View file

@ -1,7 +1,6 @@
package be.ugent.sel.studeez.timer_functional package be.ugent.sel.studeez.timer_functional
import be.ugent.sel.studeez.data.local.models.timer_functional.FunctionalCustomTimer import be.ugent.sel.studeez.data.local.models.timer_functional.FunctionalCustomTimer
import be.ugent.sel.studeez.data.local.models.timer_functional.FunctionalTimer
import org.junit.Assert import org.junit.Assert
import org.junit.Test import org.junit.Test

View file

@ -1,7 +1,6 @@
package be.ugent.sel.studeez.timer_functional package be.ugent.sel.studeez.timer_functional
import be.ugent.sel.studeez.data.local.models.timer_functional.FunctionalEndlessTimer import be.ugent.sel.studeez.data.local.models.timer_functional.FunctionalEndlessTimer
import be.ugent.sel.studeez.data.local.models.timer_functional.FunctionalTimer
import org.junit.Assert import org.junit.Assert
import org.junit.Test import org.junit.Test

View file

@ -1,7 +1,6 @@
package be.ugent.sel.studeez.timer_functional package be.ugent.sel.studeez.timer_functional
import be.ugent.sel.studeez.data.local.models.timer_functional.FunctionalPomodoroTimer import be.ugent.sel.studeez.data.local.models.timer_functional.FunctionalPomodoroTimer
import be.ugent.sel.studeez.data.local.models.timer_functional.FunctionalTimer
import org.junit.Assert import org.junit.Assert
import org.junit.Test import org.junit.Test

View file

@ -0,0 +1,99 @@
package be.ugent.sel.studeez.timer_functional
import android.media.MediaPlayer
import be.ugent.sel.studeez.data.SelectedTimerState
import be.ugent.sel.studeez.data.local.models.timer_functional.FunctionalCustomTimer
import be.ugent.sel.studeez.data.local.models.timer_functional.FunctionalEndlessTimer
import be.ugent.sel.studeez.data.local.models.timer_functional.FunctionalPomodoroTimer
import be.ugent.sel.studeez.screens.session.InvisibleSessionManager
import be.ugent.sel.studeez.screens.session.SessionViewModel
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.advanceTimeBy
import kotlinx.coroutines.test.runTest
import org.junit.Assert
import org.junit.Test
import org.mockito.kotlin.mock
@ExperimentalCoroutinesApi
class InvisibleSessionManagerTest {
private var timerState: SelectedTimerState = SelectedTimerState()
private lateinit var viewModel: SessionViewModel
private var mediaPlayer: MediaPlayer = mock()
@Test
fun InvisibleEndlessTimerTest() = runTest {
timerState.selectedTimer = FunctionalEndlessTimer()
viewModel = SessionViewModel(timerState, mock())
InvisibleSessionManager.setParameters(viewModel, mediaPlayer)
val test = launch {
InvisibleSessionManager.updateTimer()
}
Assert.assertEquals(viewModel.getTimer().time.time, 0)
advanceTimeBy(1_000) // Start tikker
advanceTimeBy(10_000_000)
Assert.assertEquals(viewModel.getTimer().time.time, 10000)
test.cancel()
return@runTest
}
@Test
fun InvisiblePomodoroTimerTest() = runTest {
val studyTime = 10
val breakTime = 5
val repeats = 1
timerState.selectedTimer = FunctionalPomodoroTimer(studyTime, breakTime, repeats)
viewModel = SessionViewModel(timerState, mock())
InvisibleSessionManager.setParameters(viewModel, mediaPlayer)
val test = launch {
InvisibleSessionManager.updateTimer()
}
Assert.assertEquals(viewModel.getTimer().time.time, 10)
advanceTimeBy(1_000) // start tikker
advanceTimeBy(9_000)
Assert.assertEquals(viewModel.getTimer().time.time, 1)
// focus, 9 sec, 1 sec nog
advanceTimeBy(2_000)
Assert.assertEquals(viewModel.getTimer().time.time, 4)
// pauze, 11 sec bezig, 4 seconden nog pauze
advanceTimeBy(5_000)
Assert.assertEquals(viewModel.getTimer().time.time, 9)
// 2e focus, 16 sec, 9 sec in 2e focus nog
advanceTimeBy(13_000)
Assert.assertTrue(viewModel.getTimer().hasEnded())
// Done
test.cancel()
return@runTest
}
@Test
fun InvisibleCustomTimerTest() = runTest {
timerState.selectedTimer = FunctionalCustomTimer(5)
viewModel = SessionViewModel(timerState, mock())
InvisibleSessionManager.setParameters(viewModel, mediaPlayer)
val test = launch {
InvisibleSessionManager.updateTimer()
}
Assert.assertEquals(viewModel.getTimer().time.time, 5)
advanceTimeBy(1_000) // Start tikker
advanceTimeBy(4_000)
Assert.assertEquals(viewModel.getTimer().time.time, 1)
advanceTimeBy(1_000)
Assert.assertEquals(viewModel.getTimer().time.time, 0)
test.cancel()
return@runTest
}
}

View file

@ -7,6 +7,8 @@
# Specifies the JVM arguments used for the daemon process. # Specifies the JVM arguments used for the daemon process.
# The setting is particularly useful for tweaking memory settings. # The setting is particularly useful for tweaking memory settings.
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
org.gradle.daemon=true
org.gradle.parallel=true
# When configured, Gradle will run in incubating parallel mode. # When configured, Gradle will run in incubating parallel mode.
# This option should only be used with decoupled projects. More details, visit # This option should only be used with decoupled projects. More details, visit
# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects # http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects