-
Notifications
You must be signed in to change notification settings - Fork 320
NAVAND-552: predownload voice instructions #6771
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Conversation
b399225
to
72f3998
Compare
2df02c7
to
2254578
Compare
Codecov Report
@@ 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
|
...i-voice/src/main/java/com/mapbox/navigation/ui/voice/api/VoiceInstructionsDownloadTrigger.kt
Outdated
Show resolved
Hide resolved
examples/src/main/java/com/mapbox/navigation/examples/core/MapboxVoiceActivity.kt
Outdated
Show resolved
Hide resolved
...i-voice/src/main/java/com/mapbox/navigation/ui/voice/api/VoiceInstructionsDownloadTrigger.kt
Outdated
Show resolved
Hide resolved
2254578
to
be42298
Compare
/** | ||
* 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) | ||
} | ||
|
There was a problem hiding this comment.
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:
- Register anything what implements
RoutesObserver
andRouteProgressObserver
as aVoiceInstructionsTrigger
. It's getting challenging to change observers from which predownload logic takes data as users could start using this registration for a different purpose. - Call
VoiceInstructionsDownloadTrigger#onRouteProgressChange
andVoiceInstructionsDownloadTrigger#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.
/** | |
* 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) | |
} |
There was a problem hiding this comment.
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.
@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) | ||
} |
There was a problem hiding this comment.
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()
?
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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())
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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
Lines 576 to 592 in ce11135
@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) | |
} | |
} |
There was a problem hiding this comment.
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
).
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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) |
There was a problem hiding this comment.
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:
- User sets a different route
- Reroute happened
- OS killed the navigation application
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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:
Line 27 in ca5276b
val file = speechFileProvider.generateVoiceFileFrom(blob.inputStream()) |
Predownloading does not create this file.
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
private fun hasTypeAndAnnouncement(typeAndAnnouncement: TypeAndAnnouncement): Boolean { | ||
synchronized(downloadedInstructionsLock) { | ||
return typeAndAnnouncement in downloadedInstructions | ||
} | ||
} |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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:
- trigger download of [A, B, C, D]
- ...
- trigger download of [D, E, F, G]
- ...
- 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.
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}") | ||
} | ||
} |
There was a problem hiding this comment.
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)
}
There was a problem hiding this comment.
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.
29acb7e
to
2399e5b
Compare
@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:
This setup is too brittle and introduces too many areas where things can go wrong. Let's simplify it by hiding the |
libnavui-voice/src/test/java/com/mapbox/navigation/ui/voice/api/MapboxVoiceApiTest.kt
Outdated
Show resolved
Hide resolved
libnavui-voice/src/main/java/com/mapbox/navigation/ui/voice/api/MapboxSpeechLoader.kt
Show resolved
Hide resolved
The initial solution (#6547) did not require developers to do anything. I'd also like to point out that additional work is required only if you use low-level API. If you use |
2399e5b
to
0a465cc
Compare
libnavigation-core/src/test/java/com/mapbox/navigation/core/MapboxNavigationTest.kt
Outdated
Show resolved
Hide resolved
b77a17c
to
babeb39
Compare
ChangelogFeaturesBug fixes and improvements
Known issues
|
dcedad6
to
6916cb5
Compare
6c8f5f5
to
bfa7703
Compare
I did a final round of testing. |
e3a6a83
to
12a2e6e
Compare
libnavui-voice/src/main/java/com/mapbox/navigation/ui/voice/api/MapboxSpeechApi.kt
Outdated
Show resolved
Hide resolved
libnavui-voice/src/main/java/com/mapbox/navigation/ui/voice/api/MapboxSpeechApi.kt
Outdated
Show resolved
Hide resolved
...e/src/main/java/com/mapbox/navigation/ui/voice/api/TimeBasedNextVoiceInstructionsProvider.kt
Show resolved
Hide resolved
libnavui-voice/src/main/java/com/mapbox/navigation/ui/voice/api/MapboxSpeechApi.kt
Outdated
Show resolved
Hide resolved
libnavui-voice/src/main/java/com/mapbox/navigation/ui/voice/api/VoiceInstructionsPrefetcher.kt
Show resolved
Hide resolved
77d5de6
to
f071a47
Compare
libnavui-voice/src/main/java/com/mapbox/navigation/ui/voice/api/VoiceInstructionsPrefetcher.kt
Show resolved
Hide resolved
1086a78
to
38d3589
Compare
Iteration 2 of #6547.