Monthly Archives: Januar 2012

Google Places Ergebnisse aus einer AutoCompleteTextView wählen

Google Places Suche in Android


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:

  1. Keys besorgen
  2. PlacesActivity implementieren
  3. PlacesAdapter implementieren
  4. PlacesService implementieren

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:

  1. Die App startet und der Nutzer sieht ein Textfeld und eine Karte
  2. Tippt er einen Ort in das Textfeld, werden mögliche Ergebnisse in einer Liste gezeigt.
  3. 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:

  1. Einen neuen PlacesService erzeugen.
  2. Die AutoCompleteTextView konfigurieren und den PlacesAdapter zuweisen.
  3. Die MapView konfigurieren.

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 AutoCompleteTextView bekommt eine eigene Adapter-Implementierung: der PlacesAdapter<Result>. Der ArrayAdapter funktioniert in diesem Zusammenhang nicht, weil der interne Filter nicht ausgetauscht werden kann. Dieser Filter ist aber der Schlüssel zu der Suche, wie wir gleich sehen werden.
  • Der PlacesAdapter verwaltet Objekte vom Typ Result. 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 PlacesService die 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:

  1. Autocomplete API, um mit einer Volltextsuche unterschiedliche Orte zu finden.
  2. 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 🙂