Skip to content
Merged
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 androidApp/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ dependencies {
implementation(libs.bundles.decompose)

implementation(libs.androidx.activity.compose)
implementation(libs.androidx.core.splashscreen)
implementation(libs.compose.uiToolingPreview)
debugImplementation(libs.compose.uiTooling)

Expand Down
2 changes: 1 addition & 1 deletion androidApp/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.BlockBlast"
android:theme="@style/Theme.BlockBlast.Starter"
android:localeConfig="@xml/locales_config">

<meta-data
Expand Down
2 changes: 2 additions & 0 deletions androidApp/src/main/kotlin/ge/yet/blockblast/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import com.arkivanov.decompose.defaultComponentContext
import com.google.firebase.Firebase
import com.google.firebase.initialize
Expand All @@ -12,6 +13,7 @@ import ge.yet3.blokblast.screen.App

class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
installSplashScreen()
enableEdgeToEdge()
super.onCreate(savedInstanceState)

Expand Down
7 changes: 7 additions & 0 deletions androidApp/src/main/res/values/themes.xml
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,11 @@
<style name="Theme.BlockBlast" parent="@android:style/Theme.Material.Light.NoActionBar">
<item name="android:windowBackground">@color/splash_background</item>
</style>

<style name="Theme.BlockBlast.Starter" parent="Theme.SplashScreen">
<item name="windowSplashScreenBackground">@color/splash_background</item>
<item name="windowSplashScreenAnimatedIcon">@mipmap/ic_launcher_foreground</item>
<item name="windowSplashScreenIconBackgroundColor">@color/splash_background</item>
<item name="postSplashScreenTheme">@style/Theme.BlockBlast</item>
</style>
</resources>
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,8 @@ private val DRAG_GHOST_VERTICAL_LIFT = 28.dp

@Composable
fun GameContent(component: GameComponent) {
val model by component.model.subscribeAsState()
val uiModel by component.model.subscribeAsState()
val model = uiModel.game
var selectedPieceId by remember { mutableStateOf<Long?>(null) }

// ── Effect states ────────────────────────────────────────────────────
Expand Down Expand Up @@ -252,10 +253,9 @@ fun GameContent(component: GameComponent) {
// owned by the GameStore (see GameStoreFactory) and projected onto a
// Value<Int> via DefaultGameComponent. ──
val interstitial = rememberGameOverInterstitial()
val continueCountdownRaw by component.continueCountdown.subscribeAsState()
// Store emits -1 (COUNTDOWN_INACTIVE) when no game-over countdown is
// active; the overlay API uses a nullable Int for the same concept.
val continueCountdown: Int? = continueCountdownRaw.takeIf { it >= 0 }
val continueCountdown: Int? = uiModel.continueCountdown.takeIf { it >= 0 }

Scaffold(
modifier = Modifier.fillMaxSize(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ import blockblast.composeapp.generated.resources.review_prompt_body
import blockblast.composeapp.generated.resources.review_prompt_dont_show
import blockblast.composeapp.generated.resources.review_prompt_leave_feedback
import blockblast.composeapp.generated.resources.review_prompt_title
import ge.yet.blockblast.feature.game.ReviewPromptComponent
import ge.yet.blockblast.feature.game.reviewprompt.ReviewPromptComponent
import ge.yet3.blokblast.component.button.PrimaryTerracottaButton
import ge.yet3.blokblast.component.button.SecondaryWarmSandButton
import org.jetbrains.compose.resources.stringResource
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import blockblast.composeapp.generated.resources.Res
import blockblast.composeapp.generated.resources.open_source_libraries
import ge.yet.blockblast.feature.settings.LibrariesSettingsComponent
import ge.yet.blockblast.feature.settings.libraries.LibrariesSettingsComponent
import ge.yet3.blokblast.component.icon.OpenInNew
import ge.yet3.blokblast.screen.settings.SettingsDivider
import ge.yet3.blokblast.screen.settings.SettingsHeader
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ import blockblast.composeapp.generated.resources.sound_subtitle
import blockblast.composeapp.generated.resources.vibration
import blockblast.composeapp.generated.resources.vibration_subtitle
import com.arkivanov.decompose.extensions.compose.subscribeAsState
import ge.yet.blockblast.feature.settings.MainSettingsComponent
import ge.yet.blockblast.feature.settings.main.MainSettingsComponent
import ge.yet3.blokblast.component.icon.DarkMode
import ge.yet3.blokblast.component.icon.NotificationsActive
import ge.yet3.blokblast.component.icon.Settings
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import blockblast.composeapp.generated.resources.open_source_libraries_subtitle
import blockblast.composeapp.generated.resources.privacy_policy
import blockblast.composeapp.generated.resources.privacy_policy_subtitle
import com.app.common.config.AppConfig
import ge.yet.blockblast.feature.settings.MoreSettingsComponent
import ge.yet.blockblast.feature.settings.more.MoreSettingsComponent
import ge.yet3.blokblast.component.icon.Github
import ge.yet3.blokblast.component.icon.OpenInNew
import ge.yet3.blokblast.component.icon.PrivacyTip
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -53,22 +53,12 @@ internal class AndroidPlatformSoundPlayer(

private var musicPlayer: MediaPlayer? = null
private var musicState: MusicState = MusicState.IDLE
private var lastTrackIndex: Int = -1

init {
pool.setOnLoadCompleteListener { _, sampleId, status ->
if (status == 0) readyIds += sampleId
}
// Pre-load common SFX so the first placement/clear isn't dropped.
// Voice files are still lazy-loaded the first time they're requested
// and will silently drop the very first play (rare and not worth a
// queue). Plain SFX are warmed here.
listOf(
"block_place",
"line_clear_1", "line_clear_2", "line_clear_3", "line_clear_4",
).forEach { name ->
val id = resolve(name)
if (id != 0) ids[name] = id
}
}

override fun playPlacement() = safePlay("block_place")
Expand All @@ -93,8 +83,14 @@ internal class AndroidPlatformSoundPlayer(
// Re-entrancy guard. PREPARING means a previous startMusic is in flight;
// do not release it from under its OnPreparedListener.
if (musicState != MusicState.IDLE) return
playTrack(MusicPlaylist.nextIndex(lastTrackIndex))
}

private fun playTrack(index: Int) {
lastTrackIndex = index
val filename = MusicPlaylist.TRACKS[index]
runCatching {
val afd = ctx.assets.openFd("${AUDIO_DIR}music_ambient.mp3")
val afd = ctx.assets.openFd("${AUDIO_DIR}$filename")
val player = MediaPlayer().apply {
setAudioAttributes(
AudioAttributes.Builder()
Expand All @@ -104,19 +100,23 @@ internal class AndroidPlatformSoundPlayer(
)
setDataSource(afd.fileDescriptor, afd.startOffset, afd.length)
afd.close()
isLooping = true
setVolume(MUSIC_VOLUME, MUSIC_VOLUME)
setOnPreparedListener {
// The user may have called stopMusic() while we were
// preparing; honor that by tearing down here instead of
// starting a stream that will immediately be stopped.
if (musicState == MusicState.PREPARING) {
musicState = MusicState.PLAYING
it.start()
} else {
runCatching { it.release() }
}
}
setOnCompletionListener { mp ->
runCatching { mp.release() }
if (musicPlayer === mp && musicState == MusicState.PLAYING) {
musicPlayer = null
musicState = MusicState.IDLE
playTrack(MusicPlaylist.nextIndex(lastTrackIndex))
}
}
setOnErrorListener { mp, _, _ ->
runCatching { mp.release() }
if (musicPlayer === mp) {
Expand All @@ -130,7 +130,6 @@ internal class AndroidPlatformSoundPlayer(
musicState = MusicState.PREPARING
player.prepareAsync()
}.onFailure {
// Asset missing or MediaPlayer construction failed — stay idle.
musicPlayer = null
musicState = MusicState.IDLE
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package ge.yet.blokblast.data.platform

internal object MusicPlaylist {
val TRACKS: List<String> = listOf(
"block.mp3",
"feltwood.mp3",
"mossy.mp3",
)

/**
* Returns a random track index that is not [previous]. Falls back to a
* random index when the playlist has a single track.
*/
fun nextIndex(previous: Int, random: kotlin.random.Random = kotlin.random.Random): Int {
if (TRACKS.size <= 1) return 0
var next = random.nextInt(TRACKS.size)
while (next == previous) next = random.nextInt(TRACKS.size)
return next
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,14 @@ internal class SettingsBackedSettingsRepository(
}
}

override suspend fun suppressReviewPrompts(max: Int) = withContext(dispatchers.io) {
writeMutex.withLock {
if (settings.getInt(KEY_REVIEW_PROMPT_COUNT, 0) < max) {
settings.putInt(KEY_REVIEW_PROMPT_COUNT, max)
}
}
}

override suspend fun setTutorialSeen() = withContext(dispatchers.io) {
settings.putBoolean(KEY_TUTORIAL_SEEN, true)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,7 @@ class DefaultAudioRepositoryTest {
override suspend fun setDarkTheme(enabled: Boolean) {}
override suspend fun setBestScore(score: Long) {}
override suspend fun incrementReviewPromptCount() {}
override suspend fun suppressReviewPrompts(max: Int) {}
override suspend fun setTutorialSeen() {}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ class DefaultVibrationRepositoryTest {
override suspend fun setDarkTheme(enabled: Boolean) {}
override suspend fun setBestScore(score: Long) {}
override suspend fun incrementReviewPromptCount() {}
override suspend fun suppressReviewPrompts(max: Int) {}
override suspend fun setTutorialSeen() {}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -94,4 +94,66 @@ class SettingsBackedSettingsRepositoryTest {
repo.setTutorialSeen()
assertTrue(repo.tutorialSeen.value)
}

// ── Race-condition / mutex coverage ──────────────────────────────────
//
// These tests run many writers concurrently against the same key. Without
// writeMutex, the read-modify-write pairs in setBestScore /
// incrementReviewPromptCount / suppressReviewPrompts would interleave and
// produce lost updates or non-monotonic state.

@Test
fun setBestScore_concurrent_writers_keep_max() = runTest {
// 100 writers shuffle values 1..100. The monotonic guard inside
// setBestScore must survive all interleavings — final value must be
// exactly the highest candidate, never anything lower.
val candidates = (1L..100L).shuffled()
candidates
.map { async(Dispatchers.Unconfined) { repo.setBestScore(it) } }
.awaitAll()
assertEquals(100L, repo.bestScore.value)
}

@Test
fun setBestScore_concurrent_lower_values_never_overwrite_higher() = runTest {
repo.setBestScore(500)
// Hammer with values below the current best. None must take effect.
(1L..200L)
.map { async(Dispatchers.Unconfined) { repo.setBestScore(it) } }
.awaitAll()
assertEquals(500L, repo.bestScore.value)
}

@Test
fun suppressReviewPrompts_concurrent_callers_settle_at_max() = runTest {
// Multiple parallel suppress calls — idempotent, must end exactly at max.
List(25) { async(Dispatchers.Unconfined) { repo.suppressReviewPrompts(max = 3) } }
.awaitAll()
assertEquals(3, repo.reviewPromptCount.value)
}

@Test
fun suppressReviewPrompts_no_op_when_already_at_or_above_max() = runTest {
repeat(5) { repo.incrementReviewPromptCount() } // count = 5
repo.suppressReviewPrompts(max = 3) // already above
assertEquals(5, repo.reviewPromptCount.value)
repo.suppressReviewPrompts(max = 5) // equal to max
assertEquals(5, repo.reviewPromptCount.value)
}

@Test
fun increment_and_suppress_concurrent_never_drops_below_max() = runTest {
// Mix N increments with one suppress(max=10). After everything settles,
// count must be at least max — the suppress floor must hold even when
// increments race against it.
val increments = List(20) {
async(Dispatchers.Unconfined) { repo.incrementReviewPromptCount() }
}
val suppress = async(Dispatchers.Unconfined) { repo.suppressReviewPrompts(max = 10) }
(increments + suppress).awaitAll()
assertTrue(
repo.reviewPromptCount.value >= 10,
"expected count ≥ 10 after concurrent suppress+increment, got ${repo.reviewPromptCount.value}",
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ import kotlinx.cinterop.addressOf
import kotlinx.cinterop.usePinned
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import platform.AVFAudio.AVAudioPlayer
Expand Down Expand Up @@ -42,15 +44,14 @@ internal class NativePlatformSoundPlayer(

private val sfxCache: MutableMap<String, AVAudioPlayer> = mutableMapOf()
private var musicPlayer: AVAudioPlayer? = null
private var musicJob: Job? = null
private var lastTrackIndex: Int = -1

/** Known SFX filenames to preload eagerly at startup. */
private val knownSfx = listOf(
"block_place.wav",
"line_clear_1.wav", "line_clear_2.wav",
"line_clear_3.wav", "line_clear_4.wav",
"voice_good.wav", "voice_great.wav",
"voice_excellent.wav", "voice_unbelievable.wav",
"voice_amazing.wav", "voice_combo.wav",
"voice_good.mp3", "voice_great.mp3",
"voice_excellent.mp3", "voice_unbelievable.mp3",
"voice_amazing.mp3",
)

init {
Expand Down Expand Up @@ -78,17 +79,28 @@ internal class NativePlatformSoundPlayer(
}

override fun startMusic() {
if (musicPlayer?.playing == true) return
scope.launch(Dispatchers.Main) {
val player = loadPlayer("music_ambient.mp3") ?: return@launch
player.numberOfLoops = -1
player.volume = MUSIC_VOLUME
musicPlayer = player
player.play()
if (musicJob?.isActive == true || musicPlayer?.playing == true) return
musicJob = scope.launch(Dispatchers.Main) {
while (true) {
val index = MusicPlaylist.nextIndex(lastTrackIndex)
lastTrackIndex = index
val filename = MusicPlaylist.TRACKS[index]
val player = loadPlayer(filename) ?: return@launch
player.numberOfLoops = 0
player.volume = MUSIC_VOLUME
musicPlayer = player
player.play()
// Sleep until the track is done, then loop to the next one.
// Add a small tail buffer to avoid an audible end-of-buffer cut.
val durationMs = (player.duration * 1000.0).toLong().coerceAtLeast(0L)
delay(durationMs + 50L)
}
}
}

override fun stopMusic() {
musicJob?.cancel()
musicJob = null
musicPlayer?.stop()
musicPlayer = null
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,12 +20,18 @@ interface SettingsRepository {
suspend fun setVibrationEnabled(enabled: Boolean)
suspend fun setDarkTheme(enabled: Boolean)

/** Write a new best score (caller is responsible for the `>` check). */
/** Monotonic write: implementations must ignore scores ≤ current best. */
suspend fun setBestScore(score: Long)

/** Increment the lifetime review-prompt counter by one. */
suspend fun incrementReviewPromptCount()

/**
* Cap the review-prompt counter at [max] so no further prompts ever fire.
* Idempotent: no-op when the counter is already at or above [max].
*/
suspend fun suppressReviewPrompts(max: Int)

/** Mark the first-launch tutorial as seen so it does not re-appear. */
suspend fun setTutorialSeen()
}
1 change: 1 addition & 0 deletions fastlane/metadata/android/en-US/changelogs/9.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Fresh tunes incoming! 🎶 The background music now rotates across multiple tracks, so your puzzle sessions stay lively. Android also gets a slicker startup with the modern SplashScreen API. Under the hood: lots of polish — cleaner components, tighter analytics, and safer settings storage. 🧩✨
Loading
Loading