Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
b908ea2
Update docs on branches and github actions
davecraig Jul 9, 2026
08a3ca4
More docs updating for branch changes
davecraig Jul 9, 2026
73244a9
Reinstate safe mode for iOS screens
davecraig Jul 11, 2026
013fa15
Debugging iOS runner
davecraig Jul 13, 2026
58cd391
Bump version to 1.1.2, version code 204
davecraig Jul 13, 2026
45334be
New documentation screenshots
davecraig Jul 13, 2026
6605f37
Fix main-thread ANR from TTS getVoices() binder call
davecraig Jul 13, 2026
6f48e63
Fix ANR from MediaPlayer.release() called inside its own completion/e…
davecraig Jul 18, 2026
3f2623a
Fix ANR: "Input dispatching timed out (No focused window)" during splash
davecraig Jul 18, 2026
ae113df
Bump version to 1.1.3, version code 205
davecraig Jul 19, 2026
a4f3050
New documentation screenshots
davecraig Jul 19, 2026
09eb532
Fix offline search for localized (name:xx) POI names
davecraig Jul 20, 2026
c24812b
Add test coverage for Photon search language fallback (Arabic etc.)
davecraig Jul 20, 2026
af896b9
Merge branch 'v1.0' into main
davecraig Jul 20, 2026
cc1835a
Improve audio beacons onboarding screen
davecraig Jul 20, 2026
644b352
Merge remote-tracking branch 'origin/v1.0'
davecraig Jul 20, 2026
178e40d
Fix offline search for localized (name:xx) POI names
davecraig Jul 20, 2026
30a69de
Add test coverage for Photon search language fallback (Arabic etc.)
davecraig Jul 20, 2026
15b58aa
Improve audio beacons onboarding screen
davecraig Jul 20, 2026
4fe3d66
Bump version to 1.1.4, version code 206
davecraig Jul 20, 2026
f9ad73c
New documentation screenshots
davecraig Jul 20, 2026
cb206a9
Bump version to 1.1.5, version code 207
davecraig Jul 21, 2026
d37e2e8
Fix ClassCastException when interpolating polygon-geometry house numbers
davecraig Jul 21, 2026
fcd80e1
Bump version to 1.1.6, version code 208
davecraig Jul 21, 2026
674baa6
New documentation screenshots
davecraig Jul 21, 2026
f94166c
Merge remote-tracking branch 'origin/v1.0'
davecraig Jul 21, 2026
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
4 changes: 2 additions & 2 deletions .github/workflows/run-tests.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -155,7 +155,7 @@ jobs:
# test sources to catch Kotlin/iOS errors; the XCTest step below
# exercises the linked code via xcodebuild.
- name: Compile shared Kotlin/Native tests for iOS simulator
run: ./gradlew :shared:compileTestKotlinIosSimulatorArm64
run: ./gradlew --configure-on-demand :shared:compileTestKotlinIosSimulatorArm64

# The runner image does not always pre-register a concrete simulator
# device (xcodebuild only sees the "Any iOS Simulator" placeholder), so
Expand Down Expand Up @@ -274,7 +274,7 @@ jobs:

- name: Boot simulator
run: |
xcrun simctl boot "$SIMULATOR_UDID" || true
open -a Simulator --args -CurrentDeviceUDID "$SIMULATOR_UDID"
xcrun simctl bootstatus "$SIMULATOR_UDID" -b

- name: Build iOS app for simulator
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withTimeoutOrNull
import org.json.JSONObject
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.scottishtecharmy.soundscape.components.LocationSource
Expand Down Expand Up @@ -531,6 +533,35 @@ class GeocoderTest {
}
}

/**
* The Photon search server is only built with English, French, German and each location's
* local language (see getPhotonLanguage()). A device whose default language is something
* else, e.g. Arabic, isn't one of those, so the language query param must be omitted rather
* than passed through - Photon then falls back to matching/returning the local language,
* which for this Egyptian POI is Arabic. This confirms that end-to-end against the real
* production Photon server, not just the language-selection logic in isolation.
*/
//@Test
// This test requires the photon server to be available - but it's unknown here
fun arabicPhotonSearchTest() {
runBlocking {
val photonGeocoder = PhotonGeocoder(PhotonSearchProvider, processor = { it.process() })
val appStrings = ComposeLocalizedStrings()

// Tenth of Ramadan City, Egypt - location of a hospital named "الهلال الاخضر"
// (Al-Hilal Al-Akhdar / "The Green Crescent") in OSM.
val nearby = LngLatAlt(31.7357622, 30.2869636)
val results = photonGeocoder.getAddressFromLocationName(
"الهلال الاخضر",
nearby,
appStrings
)
assertNotNull(results)
assertTrue(results!!.isNotEmpty())
assertTrue(results.any { it.name.contains("الهلال") })
}
}

@OptIn(ExperimentalCoroutinesApi::class)
@Test
fun osmNameAddition() {
Expand Down
Binary file modified app/src/main/assets/DoubleTap/dt_soundscape.mp3
Binary file not shown.
105 changes: 73 additions & 32 deletions app/src/main/java/org/scottishtecharmy/soundscape/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,13 @@ import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.os.Environment
import android.os.Handler
import android.os.Looper
import android.os.StrictMode
import android.provider.Settings
import android.text.Html
import android.util.Log
import android.view.View
import android.view.ViewTreeObserver
import android.view.accessibility.AccessibilityManager
import android.widget.Toast
import androidx.activity.compose.setContent
Expand Down Expand Up @@ -352,23 +354,50 @@ class MainActivity : AppCompatActivity() {

if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
val timeNow = System.currentTimeMillis()
installSplashScreen()
val splashScreen = installSplashScreen()

// Keep the splash screen visible until the sound has finished playing, with a minimum
// delay for attribution acknowledgements and a hard upper bound so that it can never
// hang if the sound never reports completion.
// hang if the sound never reports completion. maxSplashDelay must stay comfortably
// under Android's 5s "Input dispatching timed out (No focused window)" ANR timeout -
// holding the splash open (and so the real window un-drawn/unfocused) for longer than
// that risks that exact ANR if the user taps the screen during the hold.
val attributionDelay = 1500L
val maxSplashDelay = 7000L
val maxSplashDelay = 3500L
val content: View = findViewById(android.R.id.content)

var splashSoundFinished = false
// Open the splash gate, release the player and nudge a redraw so the
// OnPreDrawListener below re-evaluates its conditions.
var contentLaunched = false
// Set the real content as soon as the hold condition is met, rather than waiting for
// the splash exit animation - setOnExitAnimationListener only fires once the DecorView
// has already drawn a frame, and at that point nothing would be composed yet if we
// waited until then to call continueLaunch(), leaving a black screen once the splash
// view is removed.
val tryLaunchContent = {
val minDelayPassed = (System.currentTimeMillis() - timeNow) > attributionDelay
if (!contentLaunched && minDelayPassed && splashSoundFinished) {
contentLaunched = true
continueLaunch(isFirstLaunch)
}
}
// Open the splash gate and nudge a redraw so setKeepOnScreenCondition below
// re-evaluates its condition. Deliberately doesn't touch splashPlayer - the gate can
// open (via the maxSplashDelay safety net) before the sound has actually finished, and
// we want playback to carry on uninterrupted in the background rather than being cut
// off, so the player is only ever released once it reports real completion/error.
val openSplashGate = {
if (!splashSoundFinished) {
splashSoundFinished = true
content.invalidate()
tryLaunchContent()
}
}

// The sound has actually finished (or errored) - release the player and open the gate.
val finishSplashSound = {
splashSoundFinished = true
splashPlayer?.release()
splashPlayer = null
content.invalidate()
openSplashGate()
}

if (splashPlayed) {
Expand All @@ -384,8 +413,17 @@ class MainActivity : AppCompatActivity() {
afd.close()
player.prepare()
player.setVolume(0.7f, 0.7f)
player.setOnCompletionListener { finishSplashSound() }
player.setOnErrorListener { _, _, _ -> finishSplashSound(); true }
// Posted rather than called inline - MediaPlayer.release() called
// synchronously from within its own completion/error callback (still
// dispatching on this same EventHandler) can hang the main thread waiting on
// native teardown, causing an ANR. Posting breaks that reentrancy by letting
// the callback dispatch return first. Uses a plain main-looper Handler rather
// than content.post {} - the sound is allowed to keep playing after MainActivity
// hands off to OnboardingActivity on first launch, and by then content's window
// is gone, so a post to it would silently never run.
val mainHandler = Handler(Looper.getMainLooper())
player.setOnCompletionListener { mainHandler.post { finishSplashSound() } }
player.setOnErrorListener { _, _, _ -> mainHandler.post { finishSplashSound() }; true }
player.start()
} catch (e: Exception) {
Log.e(TAG, "Failed to play splash sound: $e")
Expand All @@ -401,24 +439,25 @@ class MainActivity : AppCompatActivity() {

// Safety net: never let the splash hang. If the sound hasn't reported completion by
// maxSplashDelay (e.g. the MediaPlayer was reclaimed before its callback could fire)
// open the gate anyway.
content.postDelayed({ if (!splashSoundFinished) finishSplashSound() }, maxSplashDelay)

content.viewTreeObserver.addOnPreDrawListener(
object : ViewTreeObserver.OnPreDrawListener {
override fun onPreDraw(): Boolean {
val minDelayPassed =
(System.currentTimeMillis() - timeNow) > attributionDelay
return if (minDelayPassed && splashSoundFinished) {
content.viewTreeObserver.removeOnPreDrawListener(this)
continueLaunch(isFirstLaunch)
true
} else {
false
}
}
}
)
// open the gate anyway - the sound (if still playing) carries on regardless.
content.postDelayed({ openSplashGate() }, maxSplashDelay)

// Covers the case where the sound finishes (or was never played) before
// attributionDelay has elapsed - tryLaunchContent() re-checks the delay itself.
content.postDelayed({ tryLaunchContent() }, attributionDelay)

// Uses the platform's own splash/focus handshake (androidx.core.splashscreen) rather
// than a hand-rolled ViewTreeObserver.OnPreDrawListener that suppresses the content
// view's first draw directly - the latter isn't coordinated with WindowManagerService's
// bookkeeping for when the window becomes focusable, which risks the same "No focused
// window" ANR mentioned above.
splashScreen.setKeepOnScreenCondition {
val minDelayPassed = (System.currentTimeMillis() - timeNow) > attributionDelay
!(minDelayPassed && splashSoundFinished)
}
splashScreen.setOnExitAnimationListener { splashScreenView ->
splashScreenView.remove()
}
}

// The remaining code in this function can be run whilst the splash screen is visible.
Expand Down Expand Up @@ -548,10 +587,12 @@ class MainActivity : AppCompatActivity() {

override fun onDestroy() {
sharedPreferences.unregisterOnSharedPreferenceChangeListener(sharedPreferencesListener)
// Release the splash sound player if it's still playing (e.g. the activity is recreated
// before playback finishes) so it doesn't leak or overlap with a fresh instance.
splashPlayer?.release()
splashPlayer = null
// Deliberately don't release a still-playing splashPlayer here: on first launch,
// continueLaunch() finishes this activity to hand off to OnboardingActivity while the
// splash sound may still be playing, and we want that playback to carry on rather than
// being cut short. It self-releases via its own completion/error listener (posted through
// a main-looper Handler, so it still fires after this activity is gone). LAST_SPLASH_RELEASE_KEY
// is committed before playback starts, so a recreated MainActivity won't start a second player.
super.onDestroy()
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,14 @@ import androidx.core.content.edit
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.preference.PreferenceManager
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.scottishtecharmy.soundscape.MainActivity
import org.scottishtecharmy.soundscape.SoundscapeServiceConnection
import org.scottishtecharmy.soundscape.screens.onboarding.audiobeacons.getBeaconResourceId
Expand Down Expand Up @@ -84,9 +86,16 @@ class SettingsViewModel(
if (initialized) {
// Only once the TextToSpeech engine is initialized can we populate the
// members of these lists.
val audioEngineTypes = audioEngine.getAvailableSpeechEngines()

val audioEngineVoiceTypes = audioEngine.getAvailableSpeechVoices()
// getAvailableSpeechEngines/getAvailableSpeechVoices end up making a
// synchronous Binder call into the system TTS service
// (ITextToSpeechService.getVoices), which can block for a long time on
// some OEM builds. Run them off the main thread to avoid ANRs.
val (audioEngineTypes, audioEngineVoiceTypes) = withContext(Dispatchers.IO) {
Pair(
audioEngine.getAvailableSpeechEngines(),
audioEngine.getAvailableSpeechVoices()
)
}
val voiceTypes = mutableListOf<String>()

// The list of voices will start of with those in the current locale
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
package org.scottishtecharmy.soundscape

import org.junit.Test
import org.scottishtecharmy.soundscape.geoengine.getPhotonLanguage
import org.scottishtecharmy.soundscape.preferences.PreferenceKeys
import org.scottishtecharmy.soundscape.preferences.PreferencesListener
import org.scottishtecharmy.soundscape.preferences.PreferencesProvider
import java.util.Locale
import kotlin.test.assertEquals
import kotlin.test.assertNull

private class FakePreferencesProvider(private val values: Map<String, String>) : PreferencesProvider {
override fun getBoolean(key: String, default: Boolean) = default
override fun getString(key: String, default: String) = values[key] ?: default
override fun getFloat(key: String, default: Float) = default
override fun putBoolean(key: String, value: Boolean) = throw NotImplementedError()
override fun putString(key: String, value: String) = throw NotImplementedError()
override fun clearAll() = throw NotImplementedError()
override fun addListener(listener: PreferencesListener) = throw NotImplementedError()
override fun removeListener(listener: PreferencesListener) = throw NotImplementedError()
}

/**
* The Photon search server (https://photon.soundscape.scottishtecharmy.org) is only built with
* English, French, German and each location's local language. Passing any other "lang" query
* param makes Photon return an error, so getPhotonLanguage() must fall back to not passing a
* language at all for unsupported languages (e.g. Arabic) - Photon then returns results matched
* and named in the local language, which is exactly what's wanted.
*/
class GeoEngineLanguageTest {
private fun withDefaultLocale(language: String, block: () -> Unit) {
val original = Locale.getDefault()
try {
Locale.setDefault(Locale.Builder().setLanguage(language).build())
block()
} finally {
Locale.setDefault(original)
}
}

@Test
fun unsupportedDeviceLanguageFallsBackToNull() {
withDefaultLocale("ar") {
assertNull(getPhotonLanguage(null))
assertNull(getPhotonLanguage(FakePreferencesProvider(mapOf(PreferenceKeys.SEARCH_LANGUAGE to "auto"))))
}
}

@Test
fun explicitSupportedLanguageOverridesDeviceLocale() {
withDefaultLocale("ar") {
assertEquals("en", getPhotonLanguage(FakePreferencesProvider(mapOf(PreferenceKeys.SEARCH_LANGUAGE to "en"))))
}
}

@Test
fun supportedDeviceLanguageIsPassedThrough() {
withDefaultLocale("de") {
assertEquals("de", getPhotonLanguage(null))
}
withDefaultLocale("fr") {
assertEquals("fr", getPhotonLanguage(FakePreferencesProvider(mapOf(PreferenceKeys.SEARCH_LANGUAGE to "auto"))))
}
}
}
46 changes: 46 additions & 0 deletions app/src/test/java/org/scottishtecharmy/soundscape/SearchTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -482,4 +482,50 @@ class SearchTest {
val buchananStreet2 = LngLatAlt(-4.3136986, 55.9455014)
testLineSearchLocation(buchananStreet2, "Buchanan Street")
}

@Test
fun multilingualSearch() {
runBlocking {
val currentLocation = LngLatAlt(-3.2003818, 55.9487360)
val gridState = getGridStateForLocation(currentLocation, MAX_ZOOM_LEVEL, GRID_SIZE)
val settlementState = getGridStateForLocation(currentLocation, 12, 3)
val tileSearch = TileSearch(offlineExtractPath, gridState, settlementState)
val offlineGeocoder = OfflineGeocoder(gridState, settlementState, tileSearch)

var results =
offlineGeocoder.getAddressFromLocationName("एडिनबर्ग किला", currentLocation, null)!!
assertEquals("एडिनबर्ग किला", results[0].name)

results =
offlineGeocoder.getAddressFromLocationName("エディンバラ城", currentLocation, null)!!
assertEquals("エディンバラ城", results[0].name)

results =
offlineGeocoder.getAddressFromLocationName("Эдинбург қамалы", currentLocation, null)!!
assertEquals("Эдинбург қамалы", results[0].name)
}
}

@Test
fun interpolatedAddressSearchWithPolygonHouseNumber() {
// Regression test for a real crash: "Carnoustie Library" (osm id 6068429362) is real
// data within glasgow-gb.pmtiles, tagged housenumber=21, street=High Street, but with
// Polygon (building outline) geometry rather than the Point geometry that
// StreetDescription.getInterpolateLocation used to assume every house number had.
// Resolving any house number on this street used to throw
// "ClassCastException: Polygon cannot be cast to Point".
runBlocking {
val currentLocation = LngLatAlt(-2.7080655097961426, 56.50118028013476)
val gridState = getGridStateForLocation(currentLocation, MAX_ZOOM_LEVEL, GRID_SIZE)

val tileSearch = TileSearch(offlineExtractPath, gridState, gridState)
val nearestWay = tileSearch.findNearestNamedWay(currentLocation, "High Street")!!

val streetDescription = StreetDescription("High Street", gridState)
streetDescription.createDescription(nearestWay, null)

val result = streetDescription.getLocationFromStreetNumber("21")
assertEquals("21", result?.second)
}
}
}
2 changes: 1 addition & 1 deletion docs/developers/branch-strategy.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ has_toc: false

# Branch strategy

This page describes which branches are active, what each is for, and how the CI workflows are wired to them. It also describes the upcoming switch to Kotlin Multiplatform (KMP) on `main`.
This page describes which branches are active, what each is for, and how the CI workflows are wired to them.

## Today

Expand Down
1 change: 0 additions & 1 deletion iosApp/iosApp/iOSApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,6 @@ struct iOSApp: App {
WindowGroup {
ZStack {
ComposeView()
.ignoresSafeArea(.all)
.onOpenURL { url in
IntentBridge.shared.handle(url: url)
}
Expand Down
2 changes: 1 addition & 1 deletion iosApp/project.yml
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,7 @@ targets:
- name: "Build Kotlin Framework"
script: |
cd "$SRCROOT/.."
./gradlew :shared:embedAndSignAppleFrameworkForXcode
./gradlew --configure-on-demand :shared:embedAndSignAppleFrameworkForXcode
basedOnDependencyAnalysis: false

iosAppTests:
Expand Down
Loading