Creating a simple News Searcher with Huawei Search Kit

nurshat

Member
Oct 7, 2015
6
3
23
Introduction
Hello reader, in this article, I am going to demonstrate how to utilize Huawei Mobile Services (HMS) Search Kit to search for news articles from the web with customizable parameters. Also, I will show you how to use tools like auto suggestions and spellcheck capabilities provided by HMS Search Kit.

Getting Started
First, we need to follow instructions on the official website to integrate Search Kit into our app.
Getting Started

After we’re done with that, let’s start coding. First, we need to initialize Search Kit in our Application/Activity.
Java:
@HiltAndroidApp   
class NewsApp : Application() {   
   override fun onCreate() {   
       super.onCreate()   
       SearchKitInstance.init(this, YOUR_APP_ID)   
   }   
}
Next, let’s not forget adding our Application class to manifest. Also to allow HTTP network requests on devices with targetSdkVersion 28 or later, we need to allow clear text traffic. (Search Kit doesn’t support minSdkVersion below 24).
XML:
<application   
       android:name=".NewsApp"   
       android:usesCleartextTraffic="true">   
       ...   
</application>
Acquiring Access Token
The token is used to verify a search request on the server. Search results of the request are returned only after the verification is successful. Therefore, before we implement any search functions, we need to get the Access Token first.
OAuth 2.0-based Authentication
If you scroll down, you will see a method called Client Credentials, which does not require authorization from a user. In this mode, your app can generate an access token to access Huawei public app-level APIs. Exactly what we need. I have used Retrofit to do this job.
Let’s create a data class that represents the token response from Huawei servers.
Java:
data class TokenResponse(val access_token: String, val expires_in: Int, val token_type: String)
Then, let’s create an interface like below to generate Retrofit Service.
Java:
interface TokenRequestService {   
   @FormUrlEncoded   
   @POST("oauth2/v3/token")   
   suspend fun getRequestToken(   
       @Field("grant_type") grantType: String,   
       @Field("client_id") clientId: String,   
       @Field("client_secret") clientSecret: String   
   ): TokenResponse   
}
Then, let’s create a repository class to call our API service.
Java:
class NewsRepository(   
   private val tokenRequestService: TokenRequestService   
) {   
   suspend fun getRequestToken() = tokenRequestService.getRequestToken(   
       "client_credentials",   
       YOUR_APP_ID,   
       YOUR_APP_SECRET   
   )   
}
You can find your App ID and App secret from console.
1_GMKWr5SChapvQoxwcIHhew.png


I have used Dagger Hilt to provide Repository for view models that need it. Here is the Repository Module class that creates the objects to be injected to view models.
Java:
@InstallIn(SingletonComponent::class)   
@Module   
class RepositoryModule {   
   @Provides   
   @Singleton   
   fun provideRepository(   
       tokenRequestService: TokenRequestService   
   ): NewsRepository {   
       return NewsRepository(tokenRequestService)   
   }   
   @Provides   
   @Singleton   
   fun providesOkHttpClient(): OkHttpClient {   
       return OkHttpClient.Builder().build()   
   }   
   @Provides   
   @Singleton   
   fun providesRetrofitClientForTokenRequest(okHttpClient: OkHttpClient): TokenRequestService {   
       val baseUrl = "https://oauth-login.cloud.huawei.com/"   
       return Retrofit.Builder()   
           .baseUrl(baseUrl)   
           .addCallAdapterFactory(CoroutineCallAdapterFactory())   
           .addConverterFactory(GsonConverterFactory.create())   
           .client(okHttpClient)   
           .build()   
           .create(TokenRequestService::class.java)   
   }   
}
In order to inject our module, we need to add @HiltAndroidApp annotation to NewsApp application class. Also, add @AndroidEntryPoint to fragments that need dependency injection. Now we can use our repository in our view models.
I have created a splash fragment to get access token, because without it, none of the search functionalities would work.
Java:
@AndroidEntryPoint   
class SplashFragment : Fragment(R.layout.fragment_splash) {   
   private var _binding: FragmentSplashBinding? = null   
   private val binding get() = _binding!!   
   private val viewModel: SplashViewModel by viewModels()   
   override fun onViewCreated(view: View, savedInstanceState: Bundle?) {   
       super.onViewCreated(view, savedInstanceState)   
       _binding = FragmentSplashBinding.bind(view)   
       lifecycleScope.launch {   
           viewModel.accessToken.collect {   
               if (it is TokenState.Success) {   
                   findNavController().navigate(R.id.action_splashFragment_to_homeFragment)   
               }   
               if (it is TokenState.Failure) {   
                   binding.progressBar.visibility = View.GONE   
                   binding.tv.text = "An error occurred, check your connection"   
               }   
           }   
       }   
   }   
   override fun onDestroyView() {   
       super.onDestroyView()   
       _binding = null   
   }   
}
Java:
class SplashViewModel @ViewModelInject constructor(private val repository: NewsRepository) :
    ViewModel() {

    private var _accessToken = MutableStateFlow<TokenState>(TokenState.Loading)
    var accessToken: StateFlow<TokenState> = _accessToken

    init {
        getRequestToken()
    }

    private fun getRequestToken() {
        viewModelScope.launch {
            try {
                val token = repository.getRequestToken().access_token
                SearchKitInstance.getInstance()
                    .setInstanceCredential(token)
                SearchKitInstance.instance.newsSearcher.setTimeOut(5000)
                Log.d(
                    TAG,
                    "SearchKitInstance.instance.setInstanceCredential done $token"
                )
                _accessToken.emit(TokenState.Success(token))
            } catch (e: Exception) {
                Log.e(HomeViewModel.TAG, "get token error", e)
                _accessToken.emit(TokenState.Failure(e))
            }
        }
    }

    companion object {
        const val TAG = "SplashViewModel"
    }
}
As you can see, once we receive our access token, we call setInstanceCredential() method with the token as the parameter. Also I have set a 5 second timeout for the News Searcher. Then, Splash Fragment should react to the change in access token flow, navigate the app to the home fragment while popping splash fragment from back stack, because we don’t want to go back there. But if token request fails, the fragment will show an error message.

Setting up Search Kit Functions
Since we have given Search Kit the token it requires, we can proceed with the rest. Let’s add three more function to our repository.

1. getNews()
This function will take two parameters — search term, and page which will be used for pagination. NewsState is a sealed class that represents two states of news search request, success or failure.
Search Kit functions are synchronous, therefore we launch them in in the Dispatchers.IO context so they don’t block our UI.
In order to start a search request, we create an CommonSearchRequest, then apply our search parameters. setQ to set search term, setLang to set in which language we want to get our news (I have selected English), setSregion to set from which region we want to get our news (I have selected whole world), setPs to set how many news we want in single page, setPn to set which page of news we want to get.
Then we call the search() method to get a response from the server. if it is successful, we get a result in the type of BaseSearchResponse<List<NewsItem>>. If it’s unsuccessful (for example there is no network connection) we get null in return. In that case It returns failure state.
Java:
class NewsRepository(
    private val tokenRequestService: TokenRequestService
) {
    ...

    suspend fun getNews(query: String, pageNumber: Int): NewsState = withContext(Dispatchers.IO) {

        var newsState: NewsState

        Log.i(TAG, "getting news $query $pageNumber")
        val commonSearchRequest = CommonSearchRequest()
        commonSearchRequest.setQ(query)
        commonSearchRequest.setLang(Language.ENGLISH)
        commonSearchRequest.setSregion(Region.WHOLEWORLD)
        commonSearchRequest.setPs(10)
        commonSearchRequest.setPn(pageNumber)
        try {
            val result = SearchKitInstance.instance.newsSearcher.search(commonSearchRequest)
            newsState = if (result != null) {
                if (result.data.size > 0) {
                    Log.i(TAG, "got news ${result.data.size}")
                    NewsState.Success(result.data)
                } else {
                    NewsState.Error(Exception("no more news"))
                }
            } else {
                NewsState.Error(Exception("fetch news error"))
            }
        } catch (e: Exception) {
            newsState = NewsState.Error(e)
            Log.e(TAG, "caught news search exception", e)
        }
        [email protected] newsState
    }

    suspend fun getAutoSuggestions(str: String): AutoSuggestionsState =
        withContext(Dispatchers.IO) {
            val autoSuggestionsState: AutoSuggestionsState
            autoSuggestionsState = try {
                val result = SearchKitInstance.instance.searchHelper.suggest(str, Language.ENGLISH)
                if (result != null) {
                    AutoSuggestionsState.Success(result.suggestions)
                } else {
                    AutoSuggestionsState.Failure(Exception("fetch suggestions error"))
                }
            } catch (e: Exception) {
                AutoSuggestionsState.Failure(e)
            }
            [email protected] autoSuggestionsState
        }

    suspend fun getSpellCheck(str: String): SpellCheckState = withContext(Dispatchers.IO) {
        val spellCheckState: SpellCheckState
        spellCheckState = try {
            val result = SearchKitInstance.instance.searchHelper.spellCheck(str, Language.ENGLISH)
            if (result != null) {
                SpellCheckState.Success(result)
            } else {
                SpellCheckState.Failure(Exception("fetch spellcheck error"))
            }
        } catch (
            e: Exception
        ) {
            SpellCheckState.Failure(e)
        }
        [email protected] spellCheckState
    }
    
    companion object {
        const val TAG = "NewsRepository"
    }
}
2. getAutoSuggestions()
Search Kit can provide search suggestions with SearchHelper.suggest() method. It takes two parameters, a String to provide suggestions for, and a language type. If the operation is successful, a result in the type AutoSuggestResponse. We can access a list of SuggestObject from suggestions field of this AutoSuggestResponse. Every SuggestObject represents a suggestion from HMS which contains a String value.

3. getSpellCheck()
It works pretty much the same with auto suggestions. SearchHelper.spellCheck() method takes the same two parameters like suggest() method. But it returns a SpellCheckResponse, which has two important fields: correctedQuery and confidence. correctedQuery is what Search Kit thinks the corrected spelling should be, confidence is how confident Search kit is about the recommendation. Confidence has 3 values, which are 0 (not confident, we should not rely on it), 1 (confident), 2 (highly confident).

Using the functions above in our app
Home Fragments has nothing to show when it launches, because nothing has been searched yet. User can click the magnifier icon in toolbar to navigate to Search Fragment. Code for Search Fragment/View Model is below.

Notes:
Search View should expand on default with keyboard showing so user can start typing right away.
Every time query text changes, it will be emitted to a flow in view model. then it will be collected by two listeners in the fragment, first one to search for auto suggestions, second one to spell check. I did this to avoid unnecessary network calls, debounce(500) will make sure subsequent entries when the user is typing fast (less than half a second for a character) will be ignored and only the last search query will be used.
When user submit query term, the string will be sent back to HomeFragment using setFragmentResult() (which is only available fragment-ktx library Fragment 1.3.0-alpha04 and above).

Java:
@AndroidEntryPoint
class SearchFragment : Fragment(R.layout.fragment_search) {

    private var _binding: FragmentSearchBinding? = null
    private val binding get() = _binding!!

    private val viewModel: SearchViewModel by viewModels()

    @FlowPreview
    @ExperimentalCoroutinesApi
    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)
        _binding = FragmentSearchBinding.bind(view)
        (activity as AppCompatActivity).setSupportActionBar(binding.toolbar)
        setHasOptionsMenu(true)

        //listen to the change in query text, trigger getSuggestions function after debouncing and filtering
        lifecycleScope.launch {
            viewModel.searchQuery.debounce(500).filter { s: String ->
                [email protected] s.length > 3
            }.distinctUntilChanged().flatMapLatest { query ->
                Log.d(TAG, "getting suggestions for term: $query")
                viewModel.getSuggestions(query).catch {
                }
            }.flowOn(Dispatchers.Default).collect {
                if (it is AutoSuggestionsState.Success) {
                    val list = it.data
                    Log.d(TAG, "${list.size} suggestion")
                    binding.chipGroup.removeAllViews()
                    //create a chip for each suggestion and add them to chip group
                    list.forEach { suggestion ->
                        val chip = Chip(requireContext())
                        chip.text = suggestion.name
                        chip.isClickable = true
                        chip.setOnClickListener {
                            //set fragment result to return search term to home fragment.
                            setFragmentResult(
                                "requestKey",
                                bundleOf("bundleKey" to suggestion.name)
                            )
                            findNavController().popBackStack()
                        }
                        binding.chipGroup.addView(chip)
                    }
                } else if (it is AutoSuggestionsState.Failure) {
                    Log.e(TAG, "suggestions request error", it.exception)
                }
            }
        }

        //listen to the change in query text, trigger spellcheck function after debouncing and filtering
        lifecycleScope.launch {
            viewModel.searchQuery.debounce(500).filter { s: String ->
                [email protected] s.length > 3
            }.distinctUntilChanged().flatMapLatest { query ->
                Log.d(TAG, "spellcheck for term: $query")
                viewModel.getSpellCheck(query).catch {
                    Log.e(TAG, "spellcheck request error", it)
                }
            }.flowOn(Dispatchers.Default).collect {
                if (it is SpellCheckState.Success) {
                    val spellCheckResponse = it.data
                    val correctedStr = spellCheckResponse.correctedQuery
                    val confidence = spellCheckResponse.confidence
                    Log.d(
                        TAG,
                        "corrected query $correctedStr confidence level $confidence"
                    )
                    if (confidence > 0) {
                        //show spellcheck layout, and set on click listener to send corrected term to home fragment
                        //to be searched
                        binding.tvDidYouMeanToSearch.visibility = View.VISIBLE
                        binding.tvCorrected.visibility = View.VISIBLE
                        binding.tvCorrected.text = correctedStr
                        binding.llSpellcheck.setOnClickListener {
                            setFragmentResult(
                                "requestKey",
                                bundleOf("bundleKey" to correctedStr)
                            )
                            findNavController().popBackStack()
                        }
                    } else {
                        binding.tvDidYouMeanToSearch.visibility = View.GONE
                        binding.tvCorrected.visibility = View.GONE
                    }

                } else if (it is SpellCheckState.Failure) {
                    Log.e(TAG, "spellcheck request error", it.exception)
                }
            }
        }
    }

    override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
        super.onCreateOptionsMenu(menu, inflater)
        inflater.inflate(R.menu.menu_search, menu)
        val searchMenuItem = menu.findItem(R.id.searchItem)
        val searchView = searchMenuItem.actionView as SearchView
        searchView.setIconifiedByDefault(false)
        searchMenuItem.expandActionView()
        searchMenuItem.setOnActionExpandListener(object : MenuItem.OnActionExpandListener {
            override fun onMenuItemActionExpand(item: MenuItem?): Boolean {
                return true
            }

            override fun onMenuItemActionCollapse(item: MenuItem?): Boolean {
                findNavController().popBackStack()
                return true
            }
        })
        searchView.setOnQueryTextListener(object : SearchView.OnQueryTextListener {
            override fun onQueryTextSubmit(query: String?): Boolean {
                return if (query != null && query.length > 3) {
                    setFragmentResult("requestKey", bundleOf("bundleKey" to query))
                    findNavController().popBackStack()
                    true
                } else {
                    Toast.makeText(requireContext(), "Search term is too short", Toast.LENGTH_SHORT)
                        .show()
                    true
                }
            }

            override fun onQueryTextChange(newText: String?): Boolean {
                viewModel.emitNewTextToSearchQueryFlow(newText ?: "")
                return true
            }
        })
    }

    override fun onDestroyView() {
        super.onDestroyView()
        _binding = null
    }

    companion object {
        const val TAG = "SearchFragment"
    }
}
Java:
class SearchViewModel @ViewModelInject constructor(private val repository: NewsRepository) :
    ViewModel() {

    private var _searchQuery = MutableStateFlow<String>("")
    var searchQuery: StateFlow<String> = _searchQuery

    fun getSuggestions(str: String): Flow<AutoSuggestionsState> {
        return flow {
            try {
                val result = repository.getAutoSuggestions(str)
                emit(result)
            } catch (e: Exception) {
            }
        }
    }

    fun getSpellCheck(str: String): Flow<SpellCheckState> {
        return flow {
            try {
                val result = repository.getSpellCheck(str)
                emit(result)
            } catch (e: Exception) {
            }
        }
    }

    fun emitNewTextToSearchQueryFlow(str: String) {
        viewModelScope.launch {
            _searchQuery.emit(str)
        }
    }
}
Now the HomeFragment has a search term to search for.
When the view is created, we receive the search term returned from Search Fragment on setFragmentResultListener. Then search for news using this query, then submit the PagingData to the recycler view adapter. Also, I made sure same flow will be returned if the new query is the same with the previous one so no unnecessary calls will be made.
Java:
@AndroidEntryPoint
class HomeFragment : Fragment(R.layout.fragment_home) {

    private var _binding: FragmentHomeBinding? = null
    private val binding get() = _binding!!

    private val viewModel: HomeViewModel by viewModels()
    private lateinit var listAdapter: NewsAdapter

    private var startedLoading = false

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)
        _binding = FragmentHomeBinding.bind(view)

        (activity as AppCompatActivity).setSupportActionBar(binding.toolbar)
        setHasOptionsMenu(true)

        listAdapter = NewsAdapter(NewsAdapter.NewsComparator, onItemClicked)
        binding.rv.adapter =
            listAdapter.withLoadStateFooter(NewsLoadStateAdapter(listAdapter))

        //if user swipe down to refresh, refresh paging adapter
        binding.swipeRefreshLayout.setOnRefreshListener {
            listAdapter.refresh()
        }

        // Listen to search term returned from Search Fragment
        setFragmentResultListener("requestKey") { _, bundle ->
            // We use a String here, but any type that can be put in a Bundle is supported
            val result = bundle.getString("bundleKey")
            binding.tv.visibility = View.GONE
            if (result != null) {
                binding.toolbar.subtitle = "News about $result"
                lifecycleScope.launchWhenResumed {
                    binding.swipeRefreshLayout.isRefreshing = true
                    viewModel.searchNews(result).collectLatest { value: PagingData<NewsItem> ->
                        listAdapter.submitData(value)
                    }
                }
            }
        }

        //need to listen to paging adapter load state to stop swipe to refresh layout animation
        //if load state contain error, show a toast.
        listAdapter.addLoadStateListener {
            if (it.refresh is LoadState.NotLoading && startedLoading) {
                binding.swipeRefreshLayout.isRefreshing = false
            } else if (it.refresh is LoadState.Error && startedLoading) {
                binding.swipeRefreshLayout.isRefreshing = false
                val loadState = it.refresh as LoadState.Error
                val errorMsg = loadState.error.localizedMessage
                Toast.makeText(requireContext(), errorMsg, Toast.LENGTH_SHORT).show()
            } else if (it.refresh is LoadState.Loading) {
                startedLoading = true
            }
        }
    }


    override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
        super.onCreateOptionsMenu(menu, inflater)
        inflater.inflate(R.menu.menu_home, menu)
    }

    override fun onOptionsItemSelected(item: MenuItem): Boolean {

        return when (item.itemId) {
            R.id.searchItem -> {
                //launch search fragment when search item clicked
                findNavController().navigate(R.id.action_homeFragment_to_searchFragment)
                true
            }
            else ->
                super.onOptionsItemSelected(item)
        }
    }

    //callback function to be passed to paging adapter, used to launch news links.
    private val onItemClicked = { it: NewsItem ->
        val builder = CustomTabsIntent.Builder()
        val customTabsIntent = builder.build()
        customTabsIntent.launchUrl(requireContext(), Uri.parse(it.clickUrl))
    }

    override fun onDestroyView() {
        super.onDestroyView()
        _binding = null
    }

    companion object {
        const val TAG = "HomeFragment"
    }
}
Java:
class HomeViewModel @ViewModelInject constructor(private val repository: NewsRepository) :
    ViewModel() {

    private var lastSearchQuery: String? = null
    var lastFlow: Flow<PagingData<NewsItem>>? = null

    fun searchNews(query: String): Flow<PagingData<NewsItem>> {

        return if (query != lastSearchQuery) {
            lastSearchQuery = query
            lastFlow = Pager(PagingConfig(pageSize = 10)) {
                NewsPagingDataSource(repository, query)
            }.flow.cachedIn(viewModelScope)
            lastFlow as Flow<PagingData<NewsItem>>
        } else {
            lastFlow!!
        }
    }

    companion object {
        const val TAG = "HomeViewModel"
    }
}
The app also uses Paging 3 library to provide endless scrolling for news articles, which is out of scope for this article, you may check the GitHub repo for how to achieve pagination with Search Kit. The end result looks like the images below.
1_s_A79PKk8lYzk3w84uuSCQ.jpeg


Check the repo here.

Tips
When Search Kit fails to fetch results (example: no internet connection), it will return null object, you can manually return an exception so you can handle the error.

Conclusion
HMS Search Kit provide easy to use APIs for fast and efficient customizable searching for web sites, images, videos and news articles in many languages and regions. Also, it provides convenient features like auto suggestions and spellchecking.

Reference
Huawei Search Kit