1 /*
2  * Copyright (C) 2022 The Android Open Source Project
3  *
4  * Licensed under the Apache License, Version 2.0 (the "License");
5  * you may not use this file except in compliance with the License.
6  * You may obtain a copy of the License at
7  *
8  *      http://www.apache.org/licenses/LICENSE-2.0
9  *
10  * Unless required by applicable law or agreed to in writing, software
11  * distributed under the License is distributed on an "AS IS" BASIS,
12  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13  * See the License for the specific language governing permissions and
14  * limitations under the License.
15  */
16 package com.android.quicksearchbox.google
17 
18 import android.content.ComponentName
19 import android.content.Context
20 import android.net.ConnectivityManager
21 import android.net.NetworkCapabilities
22 import android.os.Build
23 import android.os.Handler
24 import android.text.TextUtils
25 import android.util.Log
26 import com.android.quicksearchbox.Config
27 import com.android.quicksearchbox.R
28 import com.android.quicksearchbox.Source
29 import com.android.quicksearchbox.SourceResult
30 import com.android.quicksearchbox.SuggestionCursor
31 import com.android.quicksearchbox.util.NamedTaskExecutor
32 import java.io.BufferedReader
33 import java.io.IOException
34 import java.io.InputStream
35 import java.io.InputStreamReader
36 import java.io.UnsupportedEncodingException
37 import java.net.HttpURLConnection
38 import java.net.URI
39 import java.net.URL
40 import java.net.URLEncoder
41 import java.util.Locale
42 import org.json.JSONArray
43 import org.json.JSONException
44 
45 /** Use network-based Google Suggests to provide search suggestions. */
46 class GoogleSuggestClient(
47   context: Context?,
48   uiThread: Handler?,
49   iconLoader: NamedTaskExecutor,
50   config: Config
51 ) : AbstractGoogleSource(context, uiThread, iconLoader) {
52   private var mSuggestUri: String?
53   private val mConnectTimeout: Int
54 
55   @get:Override
56   override val intentComponent: ComponentName
57     get() = ComponentName(context!!, GoogleSearch::class.java)
58 
59   @Override
queryInternalnull60   override fun queryInternal(query: String?): SourceResult? {
61     return query(query)
62   }
63 
64   @Override
queryExternalnull65   override fun queryExternal(query: String?): SourceResult? {
66     return query(query)
67   }
68 
69   /**
70    * Queries for a given search term and returns a cursor containing suggestions ordered by best
71    * match.
72    */
querynull73   private fun query(query: String?): SourceResult? {
74     if (TextUtils.isEmpty(query)) {
75       return null
76     }
77     if (!isNetworkConnected) {
78       Log.i(LOG_TAG, "Not connected to network.")
79       return null
80     }
81     var connection: HttpURLConnection? = null
82     try {
83       val encodedQuery: String = URLEncoder.encode(query, "UTF-8")
84       if (mSuggestUri == null) {
85         val l: Locale = Locale.getDefault()
86         val language: String = GoogleSearch.getLanguage(l)
87         mSuggestUri = context?.getResources()!!.getString(R.string.google_suggest_base, language)
88       }
89       val suggestUri = mSuggestUri + encodedQuery
90       if (DBG) Log.d(LOG_TAG, "Sending request: $suggestUri")
91       val url: URL = URI.create(suggestUri).toURL()
92       connection = url.openConnection() as HttpURLConnection
93       connection.setConnectTimeout(mConnectTimeout)
94       connection.setRequestProperty("User-Agent", USER_AGENT)
95       connection.setRequestMethod("GET")
96       connection.setDoInput(true)
97       connection.connect()
98       val inputStream: InputStream = connection.getInputStream()
99       if (connection.getResponseCode() == 200) {
100 
101         /* Goto http://www.google.com/complete/search?json=true&q=foo
102          * to see what the data format looks like. It's basically a json
103          * array containing 4 other arrays. We only care about the middle
104          * 2 which contain the suggestions and their popularity.
105          */
106         val reader = BufferedReader(InputStreamReader(inputStream))
107         val sb: StringBuilder = StringBuilder()
108         var line: String?
109         while (reader.readLine().also { line = it } != null) {
110           sb.append(line).append("\n")
111         }
112         reader.close()
113         val results = JSONArray(sb.toString())
114         val suggestions: JSONArray = results.getJSONArray(1)
115         val popularity: JSONArray = results.getJSONArray(2)
116         if (DBG) Log.d(LOG_TAG, "Got " + suggestions.length().toString() + " results")
117         return GoogleSuggestCursor(this, query, suggestions, popularity)
118       } else {
119         if (DBG) Log.d(LOG_TAG, "Request failed " + connection.getResponseMessage())
120       }
121     } catch (e: UnsupportedEncodingException) {
122       Log.w(LOG_TAG, "Error", e)
123     } catch (e: IOException) {
124       Log.w(LOG_TAG, "Error", e)
125     } catch (e: JSONException) {
126       Log.w(LOG_TAG, "Error", e)
127     } finally {
128       if (connection != null) connection.disconnect()
129     }
130     return null
131   }
132 
133   @Override
refreshShortcutnull134   override fun refreshShortcut(shortcutId: String?, extraData: String?): SuggestionCursor? {
135     return null
136   }
137 
138   private val isNetworkConnected: Boolean
139     get() {
140       val actNC = activeNetworkCapabilities
141       return actNC != null && actNC.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
142     }
143   private val activeNetworkCapabilities: NetworkCapabilities?
144     get() {
145       val connectivityManager =
146         context?.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
147       val activeNetwork = connectivityManager.getActiveNetwork()
148       return connectivityManager.getNetworkCapabilities(activeNetwork)
149     }
150 
151   private class GoogleSuggestCursor(
152     source: Source,
153     userQuery: String?,
154     suggestions: JSONArray,
155     popularity: JSONArray
156   ) : AbstractGoogleSourceResult(source, userQuery!!) {
157     /* Contains the actual suggestions */
158     private val mSuggestions: JSONArray
159 
160     /* This contains the popularity of each suggestion
161      * i.e. 165,000 results. It's not related to sorting.
162      */
163     private val mPopularity: JSONArray
164 
165     @get:Override
166     override val count: Int
167       get() = mSuggestions.length()
168 
169     @get:Override
170     override val suggestionQuery: String?
171       get() =
172         try {
173           mSuggestions.getString(position)
174         } catch (e: JSONException) {
175           Log.w(LOG_TAG, "Error parsing response: $e")
176           null
177         }
178 
179     @get:Override
180     override val suggestionText2: String?
181       get() =
182         try {
183           mPopularity.getString(position)
184         } catch (e: JSONException) {
185           Log.w(LOG_TAG, "Error parsing response: $e")
186           null
187         }
188 
189     init {
190       mSuggestions = suggestions
191       mPopularity = popularity
192     }
193   }
194 
195   companion object {
196     private const val DBG = false
197     private const val LOG_TAG = "GoogleSearch"
198     private val USER_AGENT = "Android/" + Build.VERSION.RELEASE
199 
200     // TODO: this should be defined somewhere
201     private const val HTTP_TIMEOUT = "http.conn-manager.timeout"
202   }
203 
204   init {
205     mConnectTimeout = config.httpConnectTimeout
206     // NOTE:  Do not look up the resource here;  Localization changes may not have completed
207     // yet (e.g. we may still be reading the SIM card).
208     mSuggestUri = null
209   }
210 }
211