Skip to content

GIF selection fully restored with Tenor API improvements#2

Merged
ThomazFB merged 13 commits intoissue/11456-add-tenor-supportfrom
feature/reconnect-gif-picker
Mar 31, 2020
Merged

GIF selection fully restored with Tenor API improvements#2
ThomazFB merged 13 commits intoissue/11456-add-tenor-supportfrom
feature/reconnect-gif-picker

Conversation

@ThomazFB
Copy link
Copy Markdown
Owner

@ThomazFB ThomazFB commented Mar 27, 2020

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.

screencapture-1585276984938 2020-03-26 23_46_37

The solution proposes the following implementation:

  • Test suite class added to GiphyPickerDataSource in order to better enforce the business rules when consuming any GifProvider implementation.

  • The original integration with the EditPostActivity was 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

  • All Giphy packages and class naming is still missing, this will be the last modification to not storm the code review with name changes within the Pull Request
  • All Giphy string translations still to be changed to Tenor

Testing

  1. Create a Tenor API key and paste it associated with the wp.tenor.api_key as the value inside your gradle.properties
  2. Click to create a new post
  3. Click on the overflow button at the top right end of the screen
  4. select the Switch to classic editor
  5. Click on the plus (+) button at the bottom left end of the screen
  6. Select the Choose from Tenor option
  7. Type in the search bar to query GIFs
  8. Once inside the Gif Picker, make sure that selection, preview and add are all working as expected

PR submission checklist

  • I have considered adding unit tests where possible.
  • I have considered adding accessibility improvements for my changes.
  • I have considered if this change warrants user-facing release notes and have added them to RELEASE-NOTES.txt if necessary.

@ThomazFB ThomazFB changed the title Feature/reconnect gif picker Tenor API integration improved and fully restored Mar 27, 2020
@ThomazFB ThomazFB changed the title Tenor API integration improved and fully restored GIF selection fully restored with Tenor API improvements Mar 27, 2020
Copy link
Copy Markdown

@planarvoid planarvoid left a comment

Choose a reason for hiding this comment

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

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);
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

👍

onSuccess: (List<GiphyMediaViewModel>, Int?) -> Unit
) {
crossinline onSuccess: (List<GiphyMediaViewModel>, Int?) -> Unit
) = CoroutineScope(Dispatchers.Main).launch {
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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).

Copy link
Copy Markdown
Owner Author

@ThomazFB ThomazFB Mar 27, 2020

Choose a reason for hiding this comment

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

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
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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?

Copy link
Copy Markdown
Owner Author

@ThomazFB ThomazFB Mar 28, 2020

Choose a reason for hiding this comment

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

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()
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

why is this necessary? isn't the call automatically cancelled when it returns success/failure?

Copy link
Copy Markdown
Owner Author

@ThomazFB ThomazFB Mar 28, 2020

Choose a reason for hiding this comment

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

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(
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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.

Copy link
Copy Markdown
Owner Author

@ThomazFB ThomazFB Mar 28, 2020

Choose a reason for hiding this comment

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

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)
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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 👍

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

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?

@ThomazFB
Copy link
Copy Markdown
Owner Author

ThomazFB commented Mar 28, 2020

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 failure method from the WeakRefCallback wasn't getting invoked for timeout errors, I've tried to tweak the ApiService adding interceptors, but with no success.

After your review and comments saying that you had success intercepting a SocketTimeoutException I've decided to investigate and debug further the Tenor callback implementation, and I could intercept almost no exception from there, the callback reference was getting killed way too soon on my test device and the failure handling was never running, it was returning right away with an empty method call called onNetworkDropedCaught that should be overridden aside from the failure handling.

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
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

I think you can remove this now 👍

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Thank you for point this out, it's fixed in 3d8826a


@ExperimentalCoroutinesApi
@Config(application = TestApplication::class)
@RunWith(RobolectricTestRunner::class)
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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?

Copy link
Copy Markdown
Owner Author

@ThomazFB ThomazFB Mar 31, 2020

Choose a reason for hiding this comment

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

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?

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

As discussed on Slack, let's keep Roboelectric in place. Thanks for the investigation!

planarvoid
planarvoid approved these changes Mar 30, 2020
Copy link
Copy Markdown

@planarvoid planarvoid left a comment

Choose a reason for hiding this comment

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

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

@ThomazFB
Copy link
Copy Markdown
Owner Author

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.

Copy link
Copy Markdown

@planarvoid planarvoid left a comment

Choose a reason for hiding this comment

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

It looks great now! Thanks for all the effort 👍

@ThomazFB ThomazFB merged commit 1d18e1c into issue/11456-add-tenor-support Mar 31, 2020
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants