Skip to content

Conversation

dzinad
Copy link
Contributor

@dzinad dzinad commented Dec 22, 2022

Iteration 2 of #6547.

@dzinad dzinad force-pushed the NAVAND-552-dd-predownloading3 branch from b399225 to 72f3998 Compare December 22, 2022 10:55
@dzinad dzinad marked this pull request as ready for review December 22, 2022 10:55
@dzinad dzinad force-pushed the NAVAND-552-dd-predownloading3 branch 4 times, most recently from 2df02c7 to 2254578 Compare December 22, 2022 12:38
@codecov
Copy link

codecov bot commented Dec 22, 2022

Codecov Report

Merging #6771 (1086a78) into main (6fc4e70) will increase coverage by 0.10%.
The diff coverage is 97.08%.

❗ Current head 1086a78 differs from pull request most recent head 594011f. Consider uploading reports for the commit 594011f to get more accurate results

Impacted file tree graph

@@             Coverage Diff              @@
##               main    #6771      +/-   ##
============================================
+ Coverage     72.64%   72.74%   +0.10%     
- Complexity     5564     5607      +43     
============================================
  Files           781      782       +1     
  Lines         30116    30227     +111     
  Branches       3561     3574      +13     
============================================
+ Hits          21878    21989     +111     
- Misses         6810     6812       +2     
+ Partials       1428     1426       -2     
Impacted Files Coverage Δ
.../java/com/mapbox/navigation/utils/internal/Time.kt 0.00% <0.00%> (ø)
...mapbox/navigation/ui/voice/api/VoiceApiProvider.kt 0.00% <ø> (ø)
...box/navigation/ui/voice/api/MapboxAudioGuidance.kt 86.44% <100.00%> (+1.69%) ⬆️
.../mapbox/navigation/ui/voice/api/MapboxSpeechApi.kt 94.11% <100.00%> (+6.88%) ⬆️
...ox/navigation/ui/voice/api/MapboxSpeechProvider.kt 86.04% <100.00%> (+4.46%) ⬆️
...m/mapbox/navigation/ui/voice/api/MapboxVoiceApi.kt 94.73% <100.00%> (-5.27%) ⬇️
...navigation/ui/voice/api/VoiceInstructionsParser.kt 100.00% <100.00%> (ø)
...gation/ui/voice/api/VoiceInstructionsPrefetcher.kt 100.00% <100.00%> (ø)
...tion/ui/voice/internal/MapboxAudioGuidanceVoice.kt 100.00% <100.00%> (ø)
... and 4 more

@dzinad dzinad force-pushed the NAVAND-552-dd-predownloading3 branch from 2254578 to be42298 Compare January 3, 2023 10:16
Comment on lines 1286 to 1307
/**
* Subscribes voice instructions trigger observer for all the necessary updates.
*/
@ExperimentalPreviewMapboxNavigationAPI
fun <T> registerVoiceInstructionsTriggerObserver(
observer: T
) where T : RoutesObserver, T : RouteProgressObserver {
registerRoutesObserver(observer)
registerRouteProgressObserver(observer)
}

/**
* Unsubscribes voice instructions trigger observer from all the updates it was subsribed for.
*/
@ExperimentalPreviewMapboxNavigationAPI
fun <T> unregisterVoiceInstructionsTriggerObserver(
observer: T
) where T : RoutesObserver, T : RouteProgressObserver {
unregisterRoutesObserver(observer)
unregisterRouteProgressObserver(observer)
}

Copy link
Contributor

Choose a reason for hiding this comment

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

With the current API user can:

  1. Register anything what implements RoutesObserver and RouteProgressObserver as a VoiceInstructionsTrigger. It's getting challenging to change observers from which predownload logic takes data as users could start using this registration for a different purpose.
  2. Call VoiceInstructionsDownloadTrigger#onRouteProgressChange and VoiceInstructionsDownloadTrigger#onRoutesChanged that also limit our abilities in future changes as if we need more data for the new algorithm, we will have to support old usage despite the fact it's not right.

What do you think about hiding those implementation details from the user so that users can't use the API incorrectly + we are able to change it in the future? What if you implement public API as MapboxNavigationObserver with a single callback predownloadVoiceInstruction? This way API surface will be less, i.e. users has less changes to use it wrong + we have more freedom modifying it in the future.

Suggested change
/**
* Subscribes voice instructions trigger observer for all the necessary updates.
*/
@ExperimentalPreviewMapboxNavigationAPI
fun <T> registerVoiceInstructionsTriggerObserver(
observer: T
) where T : RoutesObserver, T : RouteProgressObserver {
registerRoutesObserver(observer)
registerRouteProgressObserver(observer)
}
/**
* Unsubscribes voice instructions trigger observer from all the updates it was subsribed for.
*/
@ExperimentalPreviewMapboxNavigationAPI
fun <T> unregisterVoiceInstructionsTriggerObserver(
observer: T
) where T : RoutesObserver, T : RouteProgressObserver {
unregisterRoutesObserver(observer)
unregisterRouteProgressObserver(observer)
}

Copy link
Contributor

@VysotskiVadim VysotskiVadim Jan 3, 2023

Choose a reason for hiding this comment

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

Oh, basically the same as https://github.com/mapbox/mapbox-navigation-android/pull/6771/files#r1056539683
I am not resolving the conversation because it describes motivation for the same change from a different perspective. But let's discuss it in the Kyle's comment as he suggested it first.

Comment on lines 16 to 28
@Test
fun millis() {
val tolerance = 100
val diff = abs(System.currentTimeMillis() - Time.SystemImpl.millis())
assertTrue(diff < tolerance)
}

@Test
fun nanoTime() {
val tolerance = 100000000
val diff = abs(System.nanoTime() - Time.SystemImpl.nanoTime())
assertTrue(diff < tolerance)
}
Copy link
Contributor

Choose a reason for hiding this comment

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

I still don't understand purpose of those tests 🙂
Do you think that something could go wrong in the implementation:

override fun millis(): Long = System.currentTimeMillis()

?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

"Anything that can go wrong will go wrong". :)
I'm not saying these are very important tests. It's more of a habit: I try to test all my code when possible.
Idk, you can simply copy-paste something carelessly and end up with an implementation like:

override fun millis(): Long = System. nanoTime()

Again, not very probable but since it's just a unit test I think it can't hurt.

Copy link
Contributor

Choose a reason for hiding this comment

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

agreeing with vadzim here. these tests are not needed.

It is also weird to make this a unit test, when they are really instrumentation tests. They are testing the time system calls. With unit tests, you mock deterministic values.

This unit test does not give any value, and it's essentially what the tests are doing if they are unit tests.

every { System.nanoTime } returns 100L
every { Time.SystemImpl.nanoTime() } returns 100L

assertEquals(System.nanoTime, Time.SystemImpl.nanoTime())

Copy link
Contributor

@kmadsen kmadsen Jan 17, 2023

Choose a reason for hiding this comment

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

If you want to unit test the logic of SystemImpl, it would look something like this

@Test
fun `nanoTime returns System.nanoTime`() {
  every { System.nanoTime() } returns 3999999999L

  assertEquals(3999999999L, SystemImpl.nanoTime())
}

@Test
fun `millis returns System.currentTimeMillis`() {
  every { SystemClock.currentTimeMillis() } returns 3999L

  assertEquals(3999L, SystemImpl.millis())
}

@Test
fun `seconds converts System.currentTimeMillis to seconds`() {
  every { SystemClock.currentTimeMillis() } returns 3999L

  assertEquals(3, SystemImpl.seconds())
}

The tests make it more obvious, what to expect.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Sure, that would be better but System class can't be mocked unfortunately. That's why I chose this approach.
But if you are strongly against it, I'll remove the tests.

Copy link
Contributor

Choose a reason for hiding this comment

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

There were reasons for us to prefer SystemClock over the System calls. They are actually different clocks and nav native uses SystemClock for monotonic location timestamps. Was there a reason you were preferring the System clock? I'm looking for discussions but found this comment https://github.com/mapbox/navigation-sdks/issues/636#issuecomment-691239489

Also, SystemClock has some existing tests that are mocking it

@Before
fun setup() {
mockkStatic(SystemClock::class)
every { SystemClock.elapsedRealtimeNanos() } returns deviceElapsedTimeNanos
}
@After
fun teardown() {
unmockkObject(SystemClock.elapsedRealtimeNanos())
}
private fun advanceTimeMillis(advanceMillis: Long) {
deviceElapsedTimeNanos += TimeUnit.MILLISECONDS.toNanos(advanceMillis)
every { SystemClock.elapsedRealtimeNanos() } returns deviceElapsedTimeNanos
coroutineRule.testDispatcher.advanceTimeBy(advanceMillis)
}
}

Copy link
Contributor

@kmadsen kmadsen Jan 19, 2023

Choose a reason for hiding this comment

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

but System class can't be mocked unfortunately

IMO this is a good reason to not add any logic to the this SystemImpl. Have a wrapper class that can be mockked, and make other classes have the tested logic. (we should also rename SystemImpl if we plan to use this more, like IClock or ClockWrapper).

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I didn't know there was a preference of SystemClock over System. Both can be encountered across the project code base. Here I only need it to check the time difference. There's absolutely no need to think it with NN.
I've switched to SystemClock elapsed time here. I think it makes sense.

Copy link
Contributor

Choose a reason for hiding this comment

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

According to SystemClock docs the main difference between System.nanoTime and SystemClock.elapsedRealtime(Nanos) is that the latter includes time spent in power saving modes, so it may be more preferable.

lastDownloadTime = timeProvider.seconds()
val nextInstructionsToDownload = nextVoiceInstructionsProvider
.getNextVoiceInstructions(progressData)
speechApi.predownload(nextInstructionsToDownload)
Copy link
Contributor

@VysotskiVadim VysotskiVadim Jan 3, 2023

Choose a reason for hiding this comment

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

We had this topic during the first iteration, but I still haven't got how it works. Sorry, I'm asking it again 🙂
How are the predownloaded instructions cleaned up if user interrupts the ride before the downloaded instructions are played?

For example:

  1. User sets a different route
  2. Reroute happened
  3. OS killed the navigation application

Copy link
Contributor Author

Choose a reason for hiding this comment

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

TLDR: basically they won't be cleaned. Just the way the played ones won't be cleaned.
We only clean the files that we create ourselves. However, we don't clean any caches on TileStore side. And we can't. Capturing from a Slack thread:

Resource cache is not cleared explicitly. TileStore has a unified quota for tiles and resources and will evict individual items when the quota is exceeded. Resources (including tiles) with the closest expiration date are removed until the quota is not exceeded anymore.

Copy link
Contributor

Choose a reason for hiding this comment

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

Just the way the played ones won't be cleaned.

I thought this cleans played voice instructions, doesn't it?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

It only cleans the files created here:

val file = speechFileProvider.generateVoiceFileFrom(blob.inputStream())

Predownloading does not create this file.

Copy link
Contributor

Choose a reason for hiding this comment

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

Hm, interesting 🤔
What if we have a long route, with predictive cache controller enabled (downloads tiles for the whole route ahead), and a tile store quota which is not enough to fit voice instructions and tiles for the whole route? Can downloaded voice instructions be evicted in favour of tiles before they are played? How do you think, is that scenario possible?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Can downloaded voice instructions be evicted in favour of tiles before they are played?

Right now not because they are being downloaded on-demand. After this PR - well, I think that is theoretically possible. I know that they have cache clearing mechanism planned but it's not available yet and I don't think that this is expected very soon.
Thanks for your observation, I think it's important so I'll need to understand it better. I'll talk to the Core SDK team again and run some tests.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

You're right. I was able to reproduce it in an easier way: if I set a small quota (TileStore#setOptions), some voice instructions may be removed because of the new ones, although the old ones have not yet been played.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

The other problem is that we won't know when the resource was removed from file cache by Core SDK so we might think it's downloaded although it's not.
I'll try to think of a way to handle it.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I've added a separate commit to address this issue.

Comment on lines 78 to 82
private fun hasTypeAndAnnouncement(typeAndAnnouncement: TypeAndAnnouncement): Boolean {
synchronized(downloadedInstructionsLock) {
return typeAndAnnouncement in downloadedInstructions
}
}
Copy link
Contributor

Choose a reason for hiding this comment

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

Continuation of: #6547 (comment)

the Core SDK they won't start multiple identical requests.

Will it download again the instruction which was already downloaded?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

No, it will return the one from cache.

Copy link
Contributor

Choose a reason for hiding this comment

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

why do you need downloadedInstructions then? Core SDK already handles everything, isn't it?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

It does. It's just an optimization on our side to avoid forming requests and accessing TileStore when not needed. And that might happen quite often because of the way the predownloading is triggered. Example:

  1. trigger download of [A, B, C, D]
  2. ...
  3. trigger download of [D, E, F, G]
  4. ...
  5. trigger download of [F, G, H, I]

Triggering is based on timer and with the default settings the instructions to predownload will overlap often enough. And the settings are such because we want to avoid starting the download too late.

Comment on lines 84 to 102
private fun predownload(typeAndAnnouncement: TypeAndAnnouncement) {
try {
val request = createRequest(typeAndAnnouncement)
var id: Long? = null
id = resourceLoader.load(request) { result ->
id?.let { currentRequests.remove(it) }
// tilestore thread
if (result.isValue) {
synchronized(downloadedInstructionsLock) {
downloadedInstructions.add(typeAndAnnouncement)
}
}
}
currentRequests.add(id)
} catch (ex: Throwable) {
logE("Failed to download instruction '$typeAndAnnouncement': ${ex.localizedMessage}")
}
}
Copy link
Contributor

Choose a reason for hiding this comment

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

Continuation of: #6547 (comment)

But then the requests won't be run in parallel?

They will be in parallel if you run new coroutine per voice instruction:

launch {
    predownload(typeAndAnnouncement: TypeAndAnnouncement)
}

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Oh, I see what you mean. Yes, I don't need currentRequests then, thanks.

@dzinad dzinad force-pushed the NAVAND-552-dd-predownloading3 branch from 29acb7e to 2399e5b Compare January 4, 2023 08:37
@tomaszrybakiewicz
Copy link
Contributor

@dzinad Tons of great ideas here. However, we should only push a little work on developers to make this work.

The integration of this feature seems too complicated:

  • developers need to instantiate a new class with the correct SpeachApi instance
  • remember to attach/detach to the MapboxNavigaiton instance
  • pay close attention to calling correct SpeachApi methods

This setup is too brittle and introduces too many areas where things can go wrong. Let's simplify it by hiding the VoiceInstructionsPrefetcher class and aim to make the integration as easy as flipping a boolean flag or setting a load strategy(SingleInstructionLoadStrategy, PrefetchInstructionsLoadStrategy etc.) via MapboxSpeechApiOptions that will control how instructions are loaded.

@dzinad
Copy link
Contributor Author

dzinad commented Jan 5, 2023

This setup is too brittle and introduces too many areas where things can go wrong. Let's simplify it by hiding the VoiceInstructionsPrefetcher class and aim to make the integration as easy as flipping a boolean flag or setting a load strategy(SingleInstructionLoadStrategy, PrefetchInstructionsLoadStrategy etc.) via MapboxSpeechApiOptions that will control how instructions are loaded.

The initial solution (#6547) did not require developers to do anything.
But it was discussed that doing something to enable predownloading is expected if you use low-level APIs. I think that the current solution fits quite well into the low-level API paradigm that we have.
You have instances of voiceInstructionsPlayer, speechApi, voiceInstructionsPlayerCallback, speechCallback, voiceInstructionsObserver. You bind them together to observe, play and cleanup the instructions and you register some of them as specific observers. That's exactly the way with the new voiceInstructionsPrefetcher.
We could go your way with a strategy or something. It's a nice way. But I don't see why it's much better for the current voice instructions approach that we have (reasons described above).

I'd also like to point out that additional work is required only if you use low-level API. If you use MapboxAudioGuidance, you're all set.

@dzinad dzinad force-pushed the NAVAND-552-dd-predownloading3 branch from 2399e5b to 0a465cc Compare January 5, 2023 09:22
@dzinad dzinad force-pushed the NAVAND-552-dd-predownloading3 branch from b77a17c to babeb39 Compare January 16, 2023 13:22
@github-actions
Copy link

github-actions bot commented Jan 16, 2023

Changelog

Features

Bug fixes and improvements

  • Introduced VoiceInstructionsPrefetcher MapboxSpeechAPI#generatePredownloaded to use predownloaded voice instructions instead of downloading them on demand. Example usage can be found in the examples directory ( see MapboxVoiceActivity). #dd
  • Enabled voice instructions predownloading for those who use MapboxAudioGuidance. #dd
  • Fixed an issue where with low connectivity voice instruction might have been played too late for those who use MapboxAudioGuidance. If you use MapboxSpeechAPI directly, switch to voice instructions predownloading as described above if you encounter said issue. #dd
  • Improved NavigationView camera behavior to go back into overview state if routes change during route preview state. #6840

Known issues ⚠️

Other changes

Android Auto Changelog

Features

Bug fixes and improvements

@dzinad dzinad force-pushed the NAVAND-552-dd-predownloading3 branch 2 times, most recently from dcedad6 to 6916cb5 Compare January 20, 2023 12:48
@dzinad dzinad force-pushed the NAVAND-552-dd-predownloading3 branch 3 times, most recently from 6c8f5f5 to bfa7703 Compare January 23, 2023 13:22
@dzinad
Copy link
Contributor Author

dzinad commented Jan 23, 2023

I did a final round of testing.
@kmadsen @VysotskiVadim @Zayankovsky if you want to finish your review or rereview, please do. I'd like to merge it into this week's alpha since it's quite a big change so I want it properly tested.

@dzinad dzinad force-pushed the NAVAND-552-dd-predownloading3 branch from e3a6a83 to 12a2e6e Compare January 23, 2023 14:35
@dzinad dzinad force-pushed the NAVAND-552-dd-predownloading3 branch from 77d5de6 to f071a47 Compare January 31, 2023 11:20
@dzinad dzinad force-pushed the NAVAND-552-dd-predownloading3 branch from 1086a78 to 38d3589 Compare February 1, 2023 12:19
@dzinad dzinad enabled auto-merge (squash) February 1, 2023 12:21
@dzinad dzinad merged commit 687f6ba into main Feb 1, 2023
@dzinad dzinad deleted the NAVAND-552-dd-predownloading3 branch February 1, 2023 13:04
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

5 participants