Heute habe ich mich mit einer spannenden Frage beschäftigt: Wie kann ich den Google Places Dienst ansprechen, die Suchvorschläge in einer Auswahl-Box darstellen und dann zu dem ausgewählten Ort auf einer Karte springen?
Nach einwenig hin- und her, habe ich eine ganz brauchbare Lösung gefunden, die ich nachfolgend erkläre. Folgenden Schritte sind zu tun:
- Keys besorgen
PlacesActivityimplementierenPlacesAdapterimplementierenPlacesServiceimplementieren
Die Keys
Da ich hier unterschiedliche Google Dienste nutze, benötige ich auch unterschiedliche API-Schlüssel. Bei dem ersten handelt es sich um den API-Key für Google Maps und den zweiten benötige ich um Google Places zu nutzen. Den API-Schlüssel bekomme ich aus der Console.
Vorgehensweise
Nachdem die Schlüssel besorgt sind, geht es an die Implementierung. Dabei habe ich mir folgenden Ablauf überlegt:
- Die App startet und der Nutzer sieht ein Textfeld und eine Karte
- Tippt er einen Ort in das Textfeld, werden mögliche Ergebnisse in einer Liste gezeigt.
- Klickt der Benutzer auf ein Ergebnis, springt die Karte zu dem Ort.
PlacesActivity implementieren
Ich erzeuge ein neues Android Projekt mit einer PlacesActivity. Das Layout dieser Activity ist trivial. Es gibt lediglich eine AutoCompleteTextView, die unsere Suche durchführt und die Karte.
<?xml version="1.0" encoding="utf-8"?> <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" android:layout_width="fill_parent" android:layout_height="fill_parent" android:orientation="vertical"> <AutoCompleteTextView android:id="@+id/searchField" android:layout_height="wrap_content" android:layout_width="fill_parent" android:hint="@string/place_search_hint"/> <com.google.android.maps.MapView android:id="@+id/mapView" android:layout_width="fill_parent" android:layout_height="fill_parent" android:clickable="true" android:enabled="true" android:apiKey="MAPS_API_KEY"/> </LinearLayout>
Die PlacesActivity erbt von der MapActivity und hat die folgenden Aufgaben:
- Einen neuen
PlacesServiceerzeugen. - Die
AutoCompleteTextViewkonfigurieren und denPlacesAdapterzuweisen. - Die
MapViewkonfigurieren.
public class PlacesActivity extends MapActivity { private static final String TAG = "PlacesActivity"; private static final String PLACES_API_KEY = "Your places key"; private PlacesService placesService; private MapController mapController; public void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); this.placesService = new GooglePlacesService(PLACES_API_KEY); setContentView(R.layout.places); initializeSearch(); initializeMap(); } private void initializeSearch() { final AutoCompleteTextView autoCompleteTextView = (AutoCompleteTextView) findViewById(R.id.searchField); final PlacesAdapter<PlacesService.Result> adapter = new PlacesAdapter<PlacesService.Result>(this, placesService, R.layout.places_search_item); autoCompleteTextView.setAdapter(adapter); autoCompleteTextView.setThreshold(3); autoCompleteTextView.setOnItemClickListener(new AdapterView.OnItemClickListener() { @Override public void onItemClick(AdapterView<?> adapterView, View view, int pos, long id) { onPlaceSelected(adapter.getItem(pos)); } }); } private void onPlaceSelected(PlacesService.Result item) { if (item != null) { final PlacesService.Place details = placesService.getDetails(item.reference); if (mapController != null) { mapController.animateTo(details.getGeoPoint()); } } } private void initializeMap() { final MapView mapView = (MapView) findViewById(R.id.mapView); this.mapController = mapView.getController(); mapView.setBuiltInZoomControls(true); } @Override protected boolean isRouteDisplayed() { return false; } }
In der PlacesActivity passieren schonmal die interessantesten Dinge:
- Die
AutoCompleteTextViewbekommt eine eigeneAdapter-Implementierung: derPlacesAdapter<Result>. DerArrayAdapterfunktioniert in diesem Zusammenhang nicht, weil der interneFilternicht ausgetauscht werden kann. DieserFilterist aber der Schlüssel zu der Suche, wie wir gleich sehen werden. - Der
PlacesAdapterverwaltet Objekte vom TypResult. Das sind einfache Beans, die den Namen des Suchergebnisses und eine ein eindeutige Referenznummer speichern:
public static class Result { /** * The name to display */ public final String name; /** * The key of the place */ public final String reference; /** * Creates a new result with a name and a key * * @param name The name of the place * @param reference The unique key of the place */ public Result(String name, String reference) { this.name = name; this.reference = reference; } @Override public String toString() { return name; } }
- Wenn der Benutzer eins der Ergebnisse aus der Liste auswählt, lädt der
PlacesServicedie Details zu der übergebenen Referenz und springt mit der Karte zu den Geo-Koordinaten.
PlacesAdapter implementieren
Die Implementierung des Adapters sieht so aus:
public class PlacesAdapter<T> extends BaseAdapter implements Filterable { private final List<T> items = new ArrayList<T>(); private final int textResource; private final LayoutInflater inflater; private final PlacesService placesService; private PlacesFilter filter; public PlacesAdapter(Context context, PlacesService placesService, int textResource) { this.textResource = textResource; this.placesService = placesService; this.inflater = (LayoutInflater) context.getSystemService(Context.LAYOUT_INFLATER_SERVICE); } @Override public int getCount() { return items.size(); } @Override public T getItem(int i) { return items.get(i); } @Override public long getItemId(int position) { return position; } @Override public View getView(int position, View currentView, ViewGroup parent) { return createViewFromResource(position, currentView, parent, textResource); } private View createViewFromResource(int position, View convertView, ViewGroup parent, int resource) { final TextView text = (TextView) inflater.inflate(resource, parent, false); text.setText(getItem(position).toString()); return text; } @Override public Filter getFilter() { if (filter == null) { filter = new PlacesFilter(); } return filter; } protected class PlacesFilter extends Filter { private final static String TAG = "PlacesFilter"; @Override protected FilterResults performFiltering(CharSequence charSequence) { final FilterResults results = new FilterResults(); if (charSequence != null && charSequence.length() >= 3) { results.values = placesService.find(String.valueOf(charSequence)); } return results; } @Override protected void publishResults(CharSequence charSequence, FilterResults filterResults) { notifyDataSetInvalidated(); @SuppressWarnings({"unchecked"}) final List<T> list = (List<T>) filterResults.values; if (list != null && list.size() > 0) { items.clear(); for (T result : list) { items.add(result); } Log.i(TAG, "Publish results: " + list); notifyDataSetChanged(); } } } }
Was zunächst einmal wichtig zu wissen ist, dass der BaseAdapter von sich aus schonmal asynchron arbeitet. D.h. die Methode performFiltering() wird in einem eigenen Thread aufgerufen. Ich muss mich also nicht darum kümmern, dass mein PlacesService asynchron sucht.
Weil die AutoCompleteTextView einen Adapter erwartet, der das Filterable Interface implementiert, nutze ich meine Chance einen eigenen Filter zu bauen. Dieser Filter sucht anders als der ArrayAdapter.Filter nicht in den aktuellen Elementen, sondern lässt den PlacesService die Suche durchführen und ersetzt die Elemente des PlaceAdapters, wenn neue Ergebnisse vorliegen. Das ist schon der Trick.
PlacesService implementieren
Nun geht es um die Kommunikation mit dem Backend – in diesem Fall den Google Places API Server. Dabei nutze ich eigentlich zwei unterschiedliche Dienste:
- Autocomplete API, um mit einer Volltextsuche unterschiedliche Orte zu finden.
- Place Details API, um mehr über einen bestimmten Ort zu erfahren. z.B. Geo-Koordinaten.
Der erste Dienst liefert zu jedem Suchergebnis eine Referenznummer. Der zweite Dienst liefert zu jeder Referenznummer genaue Informationen. Und das habe ich als Schnittstelle definiert:
public interface PlacesService<S> { /** * By querying a place api the results will be returned as instances of this class. */ public static class Result { /** * The name to display */ public final String name; /** * The key of the place */ public final String reference; /** * Creates a new result with a name and a key * * @param name The name of the place * @param reference The unique key of the place */ public Result(String name, String reference) { this.name = name; this.reference = reference; } @Override public String toString() { return name; } } /** * A {@link Place} extends the simple search result and contains * latitude and longitude coordinates. */ public static class Place extends Result { public final double lat; public final double lng; /** * Creates a new place with the given name and reference. * * @param name The name * @param reference The reference * @param lat The geo coordinate latitude * @param lng The geo coordinate latitude */ public Place(String name, String reference, double lat, double lng) { super(name, reference); this.lat = lat; this.lng = lng; } @Override public String toString() { final StringBuilder sb = new StringBuilder(); sb.append("Place"); sb.append("{name=").append(name); sb.append(", lat=").append(lat); sb.append(", lng=").append(lng); sb.append('}'); return sb.toString(); } public GeoPoint getGeoPoint() { return new GeoPoint((int) (1e6 * lat), (int) (1e6 * lng)); } } /** * Will be thrown if there was an error during querying the service. */ public static class QueryException extends RuntimeException { public QueryException(String detailMessage) { super(detailMessage); } public QueryException(String detailMessage, Throwable throwable) { super(detailMessage, throwable); } } /** * Query the service and returns the results * * @param query The string query * @return The result * @throws QueryException If there was an error during querying the service. */ public List<Result> find(String query) throws QueryException; /** * Returns the {@link Place} details for the given reference * * @param reference The place reference * @return The details or null, if there was not such place * @throws QueryException If there was an error during querying the service. */ public Place getDetails(String reference) throws QueryException; /** * Returns the settings of this service. * * @return The current settings */ public S getSettings(); }
Und die passende Implementierung dazu sieht so aus:
public class GooglePlacesService implements PlacesService<GooglePlacesService.Settings> { private static final String STATUS = "status"; private static final String OK = "OK"; private static final String ZERO_RESULTS = "ZERO_RESULTS"; private static final String OVER_QUERY_LIMIT = "OVER_QUERY_LIMIT"; private static final String REQUEST_DENIED = "REQUEST_DENIED"; private static final String INVALID_REQUEST = "INVALID_REQUEST"; private static final String PREDICTIONS = "predictions"; private static final String DESCRIPTION = "description"; private static final String REFERENCE = "reference"; private static final String RESULT = "result"; private static final String NAME = "name"; private static final String GEOMETRY = "geometry"; private static final String LOCATION = "location"; private static final String LAT = "lat"; private static final String LNG = "lng"; /** * To control the {@link GooglePlacesService} you have to pass some settings */ public static class Settings { public final String apiKey; public String host = "https://maps.googleapis.com"; public String searchPath = "/maps/api/place/autocomplete/json"; public String detailsPath = "/maps/api/place/details/json"; public UrlReader reader; public Settings(String apiKey) { this(apiKey, new GullibleUrlReader()); } public Settings(String apiKey, UrlReader reader) { this.apiKey = apiKey; this.reader = reader; } } /** * The current service {@link Settings} */ private Settings settings; /** * Creates a new {@link GooglePlacesService} with default settings. * * @param apiKey The google places server api key */ public GooglePlacesService(String apiKey) { this(new Settings(apiKey)); } /** * Creates a new {@link GooglePlacesService} with the given {@link Settings} * * @param settings The {@link Settings} */ public GooglePlacesService(Settings settings) { this.settings = settings; } @Override public List<Result> find(String query) throws QueryException { final Map<String, String> params = new HashMap<String, String>(); params.put("sensor", "true"); params.put("input", query); params.put("key", getSettings().apiKey); try { final String result = getSettings().reader.fetch(getSettings().host, getSettings().searchPath, params); return convertResultList(result); } catch (Exception e) { throw new QueryException("Could not query google places service", e); } } @Override public Place getDetails(String reference) throws QueryException { final Map<String, String> params = new HashMap<String, String>(); params.put("sensor", "true"); params.put("reference", reference); params.put("key", getSettings().apiKey); try { final String result = getSettings().reader.fetch(getSettings().host, getSettings().detailsPath, params); return convertPlace(result); } catch (Exception e) { throw new QueryException("Could not query google places service", e); } } /** * Converts the received google places api result into a list of result objects * * @param json The received JSON string. * @return A {@link java.util.List} of converted results */ public List<Result> convertResultList(String json) { try { final JSONObject object = new JSONObject(json); assertResponseStatus(object); return convertResultList(object); } catch (JSONException e) { throw new QueryException("invalid json response: " + json); } } /** * Converts the given json-String to a {@link Place} * * @param json String json-String * @return The converted {@link Place} */ public Place convertPlace(String json) { try { final JSONObject object = new JSONObject(json); assertResponseStatus(object); return convertPlace(object); } catch (JSONException e) { throw new QueryException("invalid json response: " + json); } } @Override public Settings getSettings() { return settings; } /** * Converts the given {@link JSONObject} to an array of understandable {@link Result} instances * * @param object The {@link JSONObject} * @return A list with objects * @throws org.json.JSONException If the json object could not be parsed */ private List<Result> convertResultList(JSONObject object) throws JSONException { final ArrayList<Result> list = new ArrayList<Result>(); // The predictions contains the places final JSONArray predictions = object.has(PREDICTIONS) ? object.getJSONArray(PREDICTIONS) : null; if (predictions != null && predictions.length() > 0) { for (int i = 0; i < predictions.length(); i++) { final JSONObject place = predictions.getJSONObject(i); // Description and the unique reference are enough for our purpose list.add(new Result(place.getString(DESCRIPTION), place.getString(REFERENCE))); } } return list; } /** * Converts a {@link JSONObject} to a {@link Place} * * @param object The {@link JSONObject} * @return A {@link Place} or null * @throws JSONException If the {@link JSONObject} could not be converted */ private Place convertPlace(JSONObject object) throws JSONException { final JSONObject result = object.getJSONObject(RESULT); final String name = result.getString(NAME); final String reference = result.getString(REFERENCE); final JSONObject location = result.getJSONObject(GEOMETRY).getJSONObject(LOCATION); final double lat = location.getDouble(LAT); final double lng = location.getDouble(LNG); return new Place(name, reference, lat, lng); } /** * Makes sure the given object is valid. Otherwise an exception will * be thrown. * * @param response The object * @throws QueryException If the status is not {@link GooglePlacesService#OK} * @throws JSONException If the json could not be parsed properly */ private void assertResponseStatus(JSONObject response) throws QueryException, JSONException { // Is something wrong if (!OK.equals(response.getString(STATUS))) { // Query limit reached if (OVER_QUERY_LIMIT.equals(response.getString(STATUS))) { throw new QueryException("query limit reached"); } // Request denied else if (REQUEST_DENIED.equals(response.getString(STATUS))) { throw new QueryException("request denied"); } // Unknown error else if (INVALID_REQUEST.equals(response.getString(STATUS))) { throw new QueryException("input missing"); } // Result is empty else if (ZERO_RESULTS.equals(response.getString(STATUS))) { // That is not problem for us. Just an empty list will be returned } else { throw new QueryException("unknown error: " + response.getString(STATUS)); } } } }
Ich denke nicht, dass es im Detail notwendig ist zu erklären, wie der Service arbeitet. Er ruft den Google Server auf und interpretiert die Antwort als JSON.
Das einzige Detail, auf das ich die Aufmerksamkeit richten möchte ist der UrlReader in den Settings.
public interface UrlReader { /** * Builds an opens an url and returns the response as string. * * @param host The host * @param path The path * @param parameters A {@link Map} with parameters * @return The server response */ public String fetch(String host, String path, Map<String, String> parameters); }
Die Kommunikation findet nämlich mit einem SSL Server statt. Beim Versuch die Verbindung zu öffnen, scheiterte der Code mit einer IOException. Das SSL-Zertifikat der Gegenstelle wurde von Android nicht akzeptiert. Da es sich hier um keine sensiblen Daten handelt, habe ich mich dazu entschieden das Zertifikat zu ignorieren und einen GullibleUrlReader implementiert, der die Verbindung leichtgläubig akzeptiert:
public class GullibleUrlReader implements UrlReader { private final X509TrustManager trustManager; private final HostnameVerifier hosteNameVerifier; /** * If the backend is a https sever than the identity will not be checked. */ public GullibleUrlReader() { this.trustManager = new EverythingTrustingManager(); this.hosteNameVerifier = new AllowAllHostnameVerifier(); } @Override public String fetch(String host, String path, Map<String, String> parameters) { try { // Build the request url final URL url = buildUrl(host, path, parameters); final URLConnection urlConnection = url.openConnection(); makeGullible(urlConnection); final InputStream inputStream = urlConnection.getInputStream(); // Read server input final BufferedReader reader = new BufferedReader(new InputStreamReader(inputStream)); final StringBuilder builder = new StringBuilder(); String line; do { line = reader.readLine(); if (line != null) { builder.append(line); } } while (line != null); return builder.toString(); } catch (MalformedURLException mue) { throw new RuntimeException("Could not build url.", mue); } catch (IOException ioe) { throw new RuntimeException("There is a problem to connect to host: ", ioe); } } /** * Build and return a new {@link URL} * * @param host The host part * @param path The path part * @param parameters The parameters (will be URL-encoded) * @return The url * @throws MalformedURLException If the url could not be build */ public URL buildUrl(String host, String path, Map<String, String> parameters) throws MalformedURLException { final StringBuilder builder = new StringBuilder(host); // Append a trailing slash if not present between the host and path if (!host.endsWith("/") && !path.startsWith("/")) { builder.append("/"); } // Append the path builder.append(path); // Append the params and encode them if (parameters != null && parameters.size() > 0) { builder.append("?"); for (String key : parameters.keySet()) { parameters.put(key, URLEncoder.encode(parameters.get(key))); } builder.append(join(join(parameters, "="), "&")); } // Build new URL from spec return new URL(builder.toString()); } /** * Joins the {@link Map} keys and values: * "KEY" + joinString + "VALUE" * * @param parameters The {@link Map} with keys and values to join * @param joinString The delimiter * @return A list of joined key-value pairs */ private List<String> join(Map<String, String> parameters, String joinString) { final ArrayList<String> result = new ArrayList<String>(); for (String key : parameters.keySet()) { result.add(new StringBuilder(key).append(joinString).append(parameters.get(key)).toString()); } return result; } /** * Join the {@link List} elements * * @param list The {@link List} with elements to join * @param joinString The delimiter * @return The joined elements of the {@link List} */ private String join(List<String> list, String joinString) { final StringBuilder builder = new StringBuilder(); for (String string : list) { if (builder.length() > 0) { builder.append(joinString); } builder.append(string); } return builder.toString(); } /** * If the given {@link URLConnection} is a {@link HttpsURLConnection} * * @param urlConnection The {@link URLConnection} that should be made gullible */ private void makeGullible(URLConnection urlConnection) { if (urlConnection instanceof HttpsURLConnection) { final HttpsURLConnection httpsConnection = (HttpsURLConnection) urlConnection; httpsConnection.setHostnameVerifier(hosteNameVerifier); try { SSLContext context = SSLContext.getInstance("TLS"); context.init(null, new X509TrustManager[]{ trustManager }, new SecureRandom()); httpsConnection.setSSLSocketFactory(context.getSocketFactory()); } catch (Exception e) { // Ignore... } } } /** * This {@link X509TrustManager} does not check the identity. */ private final class EverythingTrustingManager implements X509TrustManager { public void checkClientTrusted(X509Certificate[] chain, String authType) throws CertificateException {} public void checkServerTrusted(X509Certificate[] chain, String authType) throws CertificateException {} public X509Certificate[] getAcceptedIssuers() { return new X509Certificate[0]; } } }
Fertig

