GIF selection fully restored with Tenor API improvements#2
Conversation
planarvoid
left a comment
There was a problem hiding this comment.
Thanks for the PR @ThomazFB 👍it works well in the edit post activity. Most of comments here are about the same topic. I think the TenorProvider is a bit overengineered. Its core functionality is actually quite simple. I'd try to make it more straightforward and maybe use less extensions functions, functional parameters and other fancy Kotlin stuff. All of these features make sense in certain cases but I feel like here they hurt the readability.
I think you should also remove the coroutines since the timeout can be handled on the Api service level and the callback jump to the main thread doesn't belong to the provider. I've tested this timeout and it seems to correctly throw the: java.net.SocketTimeoutException when set.
There is one more thing that's happening with a very slow internet (bear in mind I'm stress testing the functionality here :-) ) and I think that's caused by the coroutines:
java.lang.IllegalStateException: callback.onResult already called, cannot call again.
at androidx.paging.DataSource$LoadCallbackHelper.dispatchResultToReceiver(DataSource.java:313)
at androidx.paging.PositionalDataSource$LoadInitialCallbackImpl.onResult(PositionalDataSource.java:233)
at org.wordpress.android.viewmodel.giphy.GiphyPickerDataSource$requestInitialSearch$1.invoke(GiphyPickerDataSource.kt:95)
at org.wordpress.android.viewmodel.giphy.GiphyPickerDataSource$requestInitialSearch$1.invoke(GiphyPickerDataSource.kt:14)
at org.wordpress.android.viewmodel.giphy.provider.TenorProvider$search$$inlined$enqueueSearchRequest$1$lambda$1.invokeSuspend(TenorProvider.kt:147)
| public static void showGifPickerForResult(Activity activity, @NonNull SiteModel site, int requestCode) { | ||
| Map<String, String> properties = new HashMap<>(); | ||
| properties.put("from", activity.getClass().getSimpleName()); | ||
| AnalyticsTracker.track(Stat.GIF_PICKER_ACCESSED, properties); |
| onSuccess: (List<GiphyMediaViewModel>, Int?) -> Unit | ||
| ) { | ||
| crossinline onSuccess: (List<GiphyMediaViewModel>, Int?) -> Unit | ||
| ) = CoroutineScope(Dispatchers.Main).launch { |
There was a problem hiding this comment.
I don't think the provider should be a place that decides on which thread the callback is called. This should be responsibility of the View that calls the method. Generally I'd expect the whole search call to happen on the main thread (and the tenor library to handle the background work itself since it implements an enqueue/callback mechanism).
There was a problem hiding this comment.
I agree. We should take advantage of the retrofit callback thread dispatching. I've fixed this point in baade56. I've also added at the bottom a final comment given further context about my solution and how I decided to proceed.
| response: GifsResponse, | ||
| onSuccess: (List<GiphyMediaViewModel>, Int?) -> Unit | ||
| ) { | ||
| crossinline onSuccess: (List<GiphyMediaViewModel>, Int?) -> Unit |
There was a problem hiding this comment.
I think passing these callback methods as params makes the code too complicated. Can you just have a simple buildResponse and buildError methods that return the objects you want to create and call the onSuccess/onFailure directly in the enqueueSearchRequest?
There was a problem hiding this comment.
Yeah, I got way too exaggerated with this method extraction, and after the coroutine removal, this got even more unnecessary. I've inlined the operation directly into the onSuccess and onFailure declaration since they're simple enough to do so, it got way easier to understand now. I've fixed this point in baade56.
What do you think?
| ) = buildSearchCall(query, loadSize, position).apply { | ||
| enqueue(object : WeakRefCallback<Context, GifsResponse>(context) { | ||
| override fun success(context: Context, response: GifsResponse?) { | ||
| this@apply.cancel() |
There was a problem hiding this comment.
why is this necessary? isn't the call automatically cancelled when it returns success/failure?
There was a problem hiding this comment.
For my own surprise, I've detected that the WeakRefCallback wasn't receiving a canceled Call, it was reaching the success and failure with the isCanceled returning false. But after the manual timeout implementation removal, this kind of handling lost its purpose since we now rely on the default retrofit Callback implementation. I've fixed this point in f6123f6. I've also added at the bottom a final comment given further context about my solution and how I decided to proceed.
| * | ||
| * Method is inlined for better high-order functions performance | ||
| */ | ||
| private inline fun Call<GifsResponse>.dispatchTimeoutClock( |
There was a problem hiding this comment.
This is also way too complicated tbh. If we want to implement a timeout, we can do something like this:
val timeoutJob = scope.launch {
delay(DEFAULT_SECONDS_TO_TIMEOUT * ONE_SECOND_IN_MILLIS)
buildSearchCall.cancel()
}
and do something like this on the onSuccess/onFailure callbacks:
if (timeoutJob.isActive) {
timeoutJob.cancel()
}
I've also looked into the ApiService builder and there is actually timeout built in:
com.tenor.android.core.network.ApiService.IBuilder#timeout. I think we should use that instead.
There was a problem hiding this comment.
I decided to investigate further the implementation options and how the Tenor SDK was making use of the retrofit calls, and the solution I've found made the dispatchTimeoutClock unnecessary, so I completely removed it. The fix can be found at f6123f6. I've also added at the bottom a final comment given further context about my solution and how I decided to proceed.
Also, I liked a lot your suggestion for a timeout, it really simplifies the cancel handling, I’ll keep this solution in mind, thank you!
| val context: Context, | ||
| private val tenorClient: IApiClient | ||
| private val tenorClient: IApiClient, | ||
| private val scope: CoroutineScope = CoroutineScope(Dispatchers.Default) |
There was a problem hiding this comment.
Do you think it makes sense to make an actual use of the scope and make the whole provider aware of the lifecycle? Maybe with a cancel method? This is not necessary, I'm just wondering about what you think about it 👍
There was a problem hiding this comment.
After your review, I've decided to completely remove the coroutine usage since the manual timeout handling wasn't necessary anymore and given the fact we should let the thread handling be done internally. So right now the TenorProvider no longer receives a CoroutineScope. I've committed this in 9bc5cbb.
What do you think? Should we go for the lifecycle awareness even so?
|
Hi @planarvoid! Thank you so much for the feedback, the over-engineering topic around the TenorProvider was very helpful for me to find a more lean implementation. I've just pushed all commits and I've replied to all your suggestions. Here's what happened and what I've decided to do after your review: Timeout topic: The main issue that made me look for a custom timeout handling was that the After your review and comments saying that you had success intercepting a So I decided to replace it for the classic retrofit Callback implementation instead. That replacement worked like a charm, the failure invoke is working just fine and we're now able to intercept exceptions in a much more reliable way while still counting on the retrofit request and thread handling. Coroutines topic: Once the timeout implementation was out of the game and the retrofit Callback was brought to the enqueue, much of the coroutine usage wasn't needed anymore. And I totally agree with your suggestion to avoid thread handling decisions inside the TenorProvider. So I completely removed the usage of it from the TenorProvider. And just a final note, I've tried to reproduce the exception you got with slow internet with no success, I've slowed down a lot my emulator connection and couldn't reproduce the issue. Could you see if the issue is still happening to you after those fixes I just pushed? Thanks again for your review, please keep the feedback coming, I truly appreciate it. |
| import retrofit2.Callback | ||
| import retrofit2.Response | ||
|
|
||
| @ExperimentalCoroutinesApi |
There was a problem hiding this comment.
Thank you for point this out, it's fixed in 3d8826a
|
|
||
| @ExperimentalCoroutinesApi | ||
| @Config(application = TestApplication::class) | ||
| @RunWith(RobolectricTestRunner::class) |
There was a problem hiding this comment.
I was looking into the test and I think it could be doable without Roboelectric. I'm generally against using Roboelectric because it usually means you're not testing something right (you're testing the Roboelectric framework, not your code). Sometimes you cannot avoid it but since all you're using it for here is building the Api client (which is an interface), I think you can replace it with MockitoJUnitRunner and pass in mocked ApiClient (and verify you call its methods). What do you think about this approach?
There was a problem hiding this comment.
I've tried some further approaches trying to remove the Robolectric from the TenorProviderTestclass. The Context mocking was easy going, just needed to adjust the ApiClient.getServices(context) inside the TenorProvider and mock the getString method. But as we talked before, I didn't find a good solution for the Uri.parse, my implementation options were all some kind of workaround to avoid direct Uri usage or way too overkill for a proper solution.
I think we'll have to choose the tradeoffs between keep or remove the Robolectric. What do you think?
There was a problem hiding this comment.
As discussed on Slack, let's keep Roboelectric in place. Thanks for the investigation!
planarvoid
left a comment
There was a problem hiding this comment.
Thanks a lot for the changes, it looks really good! 👍I have one last thing ™️ about the Roboelectric test and after that I think we'll be ready to merge this PR :-).
edit: I've approved the PR previously by mistake
Thank you again for your review and feedback @planarvoid! I've replied to all your comments! Since the PR is approved by mistake, I'll wait for your call that we can proceed with the merge. |
planarvoid
left a comment
There was a problem hiding this comment.
It looks great now! Thanks for all the effort 👍
Summary
This Pull Request brings integration for the Tenor API within the pre-existent GIF selection code inside the app. As described at issue 11456.
The solution proposes the following implementation:
Test suite class added to
GiphyPickerDataSourcein order to better enforce the business rules when consuming anyGifProviderimplementation.The original integration with the
EditPostActivitywas restored, making GIF selection available again when writing or editing posts.Analytics tracking restored.
The Giphy attribution icon was removed and replaced by the Tenor attribution icon.
Known missing points for upcoming Pull Requests
Testing
wp.tenor.api_keyas the value inside yourgradle.propertiesSwitch to classic editorChoose from Tenoroptionselection,previewandaddare all working as expectedPR submission checklist
RELEASE-NOTES.txtif necessary.