지난 포스팅에 이어 계속해서 안전모 프로젝트 리뷰~ 오늘은 안전모에서 사용된 무료 API인 다음 지도 서비스 API와 카카오 내비 연동을 위한 설정 부분에 대한 포스팅을 진행해보자. 이번 포스팅을 완료하고 나면 앞으로 세 번 정도의 리뷰를 통해 전체적인 안전모 프로젝트 포스팅을 완료할 수 있을 것 같다. 요새 웹 프론트엔드 개발 부분과 파이썬 개발에 많은 시간을 할애해서 안드에 안일한 것 같은 점을 반성? 하며 안전모 프로젝트 리뷰를 진행해보자.

다음 지도 API

우선 안전모에선 단순히 지도를 보여주는 화면과 특정 키워드에 따라 검색된 결과를 마커를 통해 지도에 뿌려주는 검색 API 두 가지를 사용한다.

키 발급

우선 카카오 개발자 사이트에 접속해서 API 키 발급을 진행해야 한다. 내 애플리케이션 탭에서 앱 만들기를 클릭하고, 필요한 사항들을 작성하고 새로운 앱을 만들어준다. 새로운 앱을 만들고 개요 탭에 들어가면 아래 사진을 확인할 수 있다.(API 키는 한 번 발급 받으면 하나의 앱에서 여러 개의 API를 호출할 수 있다.) 앱 키 확인 안전모는 안드로이드 플랫폼이므로 플랫폼이 안드로이드로 설정되어 있으며, 네이티브 앱 키가 아래에서 나올 매니페스트에 들어갈 실제 키 값이라고 할 수 있다. 디버그 키 확인 지도 API를 호출하기 위해서 디버그, 릴리즈 버전에 따라서 앱의 해쉬 키를 다르게 등록해야 한다. 앱의 해쉬 키를 얻어내는 방법에는 여러가지가 있지만, 가장 간단한 방법은 아래 코드를 onCreate 메서드 안에 작성한 후 앱을 실행 시키고 logcat을 통해 확인하는 것이다. 앱의 해쉬 키를 얻어냈다면, 해당 키 값을 키 해시에 등록해준다. 기존에 디버그 키 값을 등록해두고 새롭게 릴리즈 버전으로 앱을 빌드했다면, 다른 해쉬 키 값이 제공되므로 새로운 릴리즈 키 값으로 변경해줘야 한다.

패키지명은 사용하는 패키지명을 등록하고, 마켓 URL은 위와 같이 market://details?id=”패키지명” 형식으로 등록한다. 이른바 GET 방식 마켓 등록

// 해쉬 키값 구하기.
try{
  PackageInfo info = getPackageManager().getPackageInfo(getPackageName(), PackageManager.GET_SIGNATURES);
    for (Signature signature : info.signatures) {
      MessageDigest md;
      md = MessageDigest.getInstance("SHA");
      md.update(signature.toByteArray());
      String key = new String(Base64.encode(md.digest(), 0));
      Log.d("Hash key:", "!!!!!!!"+key+"!!!!!!");
        }
    } catch (Exception e){
      Log.e("name not found", e.toString());
}

지도 API 사용 준비

정상적으로 키 해시 값 등록과 네이티브 키 값 발급이 완료되었다면, 이제 본격적으로 지도 API를 호출하기 위해 물리적인 파일 업로드 및 빌드 설정을 진행해야 한다. 앞선 포스팅에서 AndroidManifest.xml 권한 설정에 대해 알아보았으니, 권한 설정 부분을 제외한 나머지 부분에 대해서만 정리해보자.

<meta-data
    android:name="com.kakao.sdk.AppKey"
    android:value="@string/kakao_app_key" />

위 코드의 kakao_app_key 부분이 위에서 발급받은 네이티브 앱 키 값이 들어갈 자리라고 할 수 있다. 키 값을 매니페스트 파일에 등록하고 나면 여기에서 지도 SDK를 다운로드 한다. 다운로드가 완료되면 해당 SDK 파일의 압축을 풀어준다. 폴더에 여러 파일들이 함께 들어있는데 개발을 위해 실제 필요한 파일은 libDaumMapAndroid.jar, armeabi-v7a, armeabi이다.

우선, 개발 중인 프로젝트 main 폴더 밑에 jniLibs라는 폴더를 만들어준다. 해당 폴더에 armeabi-v7a, armeabi 두 폴더를 폴더채로 넣어준다. libDaumMapAndroid.jar 파일은 app > libs 폴더 밑에 넣어준다. jar 파일을 함께 빌드하기 위해 build.gradle 파일에 아래 코드를 작성한다. (안스 3 버전부터 의존성을 추가할 때 compile 부분이 implementation으로 바뀌었다.)

implementation files('libs/libDaumMapAndroid.jar')

지도 API 호출

앞서 기본적인 설정 부분을 완료했다면, 이제 실제로 다음 지도 API를 호출하여 지도를 안드로이드 화면상에 뿌려주기만 하면 된다. 안전모에선 지도 API를 호출하는 Activity가 총 세 개인데, 다른 Activity(혹은 Fragment)에서는 정보 입력 폼만 보여주는 식으로 작성해도 된다. 다음 지도 instance 특성상 내부적으로 하나를 공유해서 사용하는 특징을 갖고 있기 때문에, 여러개의 Activity에서 이를 구현해도 하나의 화면처럼 동작한다. 여기선, 구조적인 부분을 제외하고 호출 과정에 대한 코드만 살펴보자.

레이아웃 구성

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:fab="http://schemas.android.com/apk/res-auto"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:orientation="vertical"
    android:weightSum="1">
    <!--...중략 -->
    <FrameLayout
        android:layout_width="match_parent"
        android:layout_height="match_parent">

        <net.daum.mf.map.api.MapView
          android:id="@+id/map_view"
          android:layout_width="match_parent"
          android:layout_height="match_parent" />
          <!--...중략 -->
    </FrameLayout>
</LinearLayout>
<!--...중략 -->

우선 기본적으로 지도를 보여주기 위해서 프레임 레이아웃에 net.daum.mf.map.api.MapView 엘리먼트와 속성을 작성하면 해당 레이아웃 영역에 지도를 표현할 수 있다. 아래 코드는 CalloutBallon을 표현 하기 위한 xml 코드이다.

?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    android:layout_gravity="center_vertical"
    android:background="@drawable/custom_callout_balloon"
    android:orientation="vertical" >

    <LinearLayout
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:orientation="horizontal" >

        <ImageView
            android:id="@+id/badge"
            android:layout_width="wrap_content"
            android:layout_height="match_parent"
            android:layout_marginRight="5dp"
            android:adjustViewBounds="true"
            android:src="@drawable/mainlogo2" >
        </ImageView>

        <LinearLayout
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:orientation="vertical" >

            <TextView
                android:id="@+id/title"
                android:layout_width="99dp"
                android:layout_height="wrap_content"
                android:layout_gravity="center_horizontal"

                android:text=""
                android:textColor="#ff000000"
                android:textSize="14sp"
                android:textStyle="bold" />

            <TextView
                android:id="@+id/desc"
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:ellipsize="end"

                android:text=""
                android:textColor="#ff7f7f7f"
                android:textSize="14sp" />
        </LinearLayout>
    </LinearLayout>
</LinearLayout>

java 코드

우선 안전모에서 사용된 주변 시설물 검색 기능을 사용하기 위해서 기본적으로 다음 지도 API Sample 파일을 여기에서 다운로드 받는다. 해당 파일엔 여러가지 파일들이 함께 작성되어 있는데, 우리가 커스터마이징해서 사용할 부분은 Item, MapApiConst, OnFinishSearchListener, Searche, SearchActivity 클래스다. 코드에 대한 내용은 아래에서 살펴보자.

package app.cap.ajm.api;
//item 클래스
public class Item {
  public String place_name;
  public String place_url;
  public String address_name;
  public String road_address_name;
  public String phone;
  public double x;
  public double y;
  public double distance;
  public String category_group_name;
  public String category_group_code;
  public String id;
  public String placeUrl;
}

먼저 item 클래스는 Searcher 클래스 부분에서 검색 API를 호출하여 반환 받은 JSON 데이터 포멧을 객체화 하기위한 DTO 모델 클래스이다.

package app.cap.ajm.api;
//MapApiConst 클래스
public class MapApiConst {
    public static final String DAUM_MAPS_ANDROID_APP_API_KEY = "발급받은 키";
}

기존의 로컬 검색 관련 REST API는 다음 개발자 콘솔에서 키를 발급받고 호출하여 사용하였는데, 현재는 카카오 개발자 사이트로 이관 되었고, 올 해를 기점으로 대부분의 서비스가 카카오 개발자 센터에서 명시한 방식으로 호출해야 한다. 기존의 안전모에서 사용된 API 호출 URL은 v1를 사용해서 약간의 변동 사항이 있다. 새로운 이슈 발생… 참고로 기존의 apis.daum.net/local/v1/search/keyword.json? 방식을 이용한 호출은 2018년 12월 31일을 기점으로 API 호출이 불가 하다고 한다.

아무튼, 위 코드의 키 값은 앞서 발급 받은 키의 REST API 키값을 지정하면 된다.(strings.xml 파일에 리소스 형식으로 저장하고 사용해도 된다.)

package app.cap.ajm.api;

import java.util.List;
//OnFinishSearchListener 인터페이스
public interface OnFinishSearchListener {
	public void onSuccess(List<Item> itemList);
	public void onFail();
}

위의 인터페이스는 검색 API 요청 성공, 실패를 구분짓기 위해 선언된 인터페이스이며, Searcher 클래스와 실제 API를 호출하는 아래의 SearchActivity에서 사용된다.

package app.cap.ajm.api;;

import java.net.*;
import java.util.*;
import org.json.JSONArray;
import org.json.JSONObject;
//......중략

//Searcher 클래스
public class Searcher {
  //API 콜백 URL
  public static final String LOCAL_KEYWORD_SEARCH_API_FORMAT = "https://dapi.kakao.com/v2/local/search/keyword.json?query=%s&y=%s&x=%s&radius=%d&page=%d&apikey=%s";

//......중략

public void searchKeyword(Context applicationContext, String query, double latitude, double longitude, int radius, int page, String apikey, OnFinishSearchListener onFinishSearchListener) {
  this.onFinishSearchListener = onFinishSearchListener;
  if (searchTask != null) {
    searchTask.cancel(true);
    searchTask = null;
  }
  String url = buildKeywordSearchApiUrlString(query, latitude, longitude, radius, page, apikey);
  searchTask = new SearchTask();
  searchTask.execute(url);
}
//UTF-8로 키워드 인코딩 필요.
private String buildKeywordSearchApiUrlString(String query, double latitude, double longitude, int radius, int page, String apikey) {
  String encodedQuery = "";
  try {
    encodedQuery = URLEncoder.encode(query, "utf-8");
  } catch (UnsupportedEncodingException e) {
    e.printStackTrace();
  }
  return String.format(Locale.ENGLISH, LOCAL_KEYWORD_SEARCH_API_FORMAT, encodedQuery, latitude, longitude, radius, page, apikey);
}

//API 호출을 위한 메서드
private String fetchData(String urlString, Map<String, String> header) {
  try {
    URL url = new URL(urlString);
    HttpURLConnection conn = (HttpURLConnection) url.openConnection();
    conn.setReadTimeout(4000 /* milliseconds */);
    conn.setConnectTimeout(7000 /* milliseconds */);
    conn.setRequestMethod("GET"); // GET 방식으로  API 요청
    con.setRequestProperty("Authorization", "KakaoAK "+MapApiConst.DAUM_MAPS_ANDROID_APP_API_KEY); // header 부분에 앱키 작성
    conn.setDoInput(true);
    conn.connect();

    InputStream is = conn.getInputStream();
    @SuppressWarnings("resource")
    Scanner s = new Scanner(is);
    s.useDelimiter("\\A");
    String data = s.hasNext() ? s.next() : "";

    Log.w("data : ", data);

    return data;
    } catch (Exception e) {
      e.printStackTrace();
      return null;
    }
  }
  //JSON 파싱용 메서드
private List<Item> parse(String jsonString) {
  List<Item> itemList = new ArrayList<Item>();
  try {
    JSONObject reader = new JSONObject(jsonString);
    JSONArray objects = reader.getJSONArray("documents");
    for (int i = 0; i < objects.length(); i++) {
      JSONObject object = objects.getJSONObject(i);
      //item 클래스에 json 데이터 할당.
      Item item = new Item();
      item.place_name = object.getString("place_name");
      //...중략
    }
  } catch (Exception e) {
    e.printStackTrace();  
    return null;
  }
  return itemList;  
}
}

위 코드와 같이 다음 카카오 지도 검색 API는 키워드로 검색, 카테고리로 검색 두 가지 REST API 호출을 통해 JSON 포멧으로 검색 결과를 반환받을 수 있으며, query, x, y 등의 데이터와 함께 발급받은 앱키를 헤더에 담아 GET 방식으로 요청한다. 각종 파라미터에 대한 더 자세한 내용은 카카오 개발자 사이트의 REST API 로컬 개발 가이드에서 확인 할 수 있다. 위의 사용된 호출 URL은 키워드로 검색 URL 부분이다.

package app.cap.ajm.activity;

import net.daum.mf.map.api.*;
//...중략
public class SearchActivity extends FragmentActivity implements
MapView.MapViewEventListener,
MapView.POIItemEventListener{
//지도를 컨트롤 하기 위한 인스턴스.
private MapView mMapView;
//결과 CalloutBallon에 대한 값을 저장하기 위한 해쉬 맵 객체.
private HashMap<Integer, Item> mTagItemMap = new HashMap<Integer, Item>();
//...중략
  @Override
  public void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);
    setContentView(R.layout.search);
    //각종 리스너 등록.
    mMapView = (MapView)findViewById(R.id.map_view);
    mMapView.setMapViewEventListener(this);
    mMapView.setPOIItemEventListener(this);
    mMapView.setCalloutBalloonAdapter(new CustomCalloutBalloonAdapter());

    //...중략
    try {
    String query = mEditTextQuery.getText().toString();
    int radius = 10000; // 중심 좌표부터의 반경거리. 특정 지역을 중심으로 검색하려고 할 경우 사용. meter 단위 (0 ~ 10000)
    int page = 1; // 페이지 번호 (1 ~ 3). 한페이지에 15개
    GeoCoordinate geoCoordinate = mMapView.getMapCenterPoint().getMapPointGeoCoord(); //좌표 정보 지오코딩.
    double latitude = geoCoordinate.latitude; // 위도
    double longitude = geoCoordinate.longitude; // 경도
    String apikey = MapApiConst.DAUM_MAPS_ANDROID_APP_API_KEY;
    Searcher searcher = new Searcher(); // Searcher
    Toast.makeText(getApplicationContext(), String.format(getString(R.string.search_10km), query),Toast.LENGTH_SHORT).show();
    searcher.searchKeyword(getApplicationContext(), query, latitude, longitude, radius, page, apikey, new OnFinishSearchListener() {
          @Override
          public void onSuccess(List<Item> itemList) {
          mMapView.removeAllPOIItems(); // 기존 검색 결과 삭제.
          showResult(itemList); // 검색 결과 보여줌.
          @Override
          public void onFail(){
          Log.w("오류: ","오류");
            }
          });
        }catch (Exception e){
          e.printStackTrace();
        }
    }
  }

//다음 지도를 그려주기 위한 초기화 작업 구현.
  @Override
  public void onMapViewInitialized(MapView mapView) {
    ActivityCompat.requestPermissions(this, new String[]{android.Manifest.permission.ACCESS_FINE_LOCATION}, 1);
    mapView.setCurrentLocationTrackingMode(MapView.CurrentLocationTrackingMode.TrackingModeOnWithHeading);
  }
//...중략
  private void showResult(List<Item> itemList) 	{
    //화면에 보여질 영역 설정을 위한 객체.
    MapPointBounds mapPointBounds = new MapPointBounds();
    //검색 api를 통해 호출받은 List의 크기만큼 반복.
    for (int i = 0; i < itemList.size(); i++) {
      //마커와 CalloutBallon을 설정하기 위한 옵션들.
      Item item = itemList.get(i);
      MapPOIItem poiItem = new MapPOIItem();
      poiItem.setTag(i);
      //길 찾기 기능 설정을 위해 해당 POI(관심지점) 객체에 mapPoint(경위도 좌표 값)를 등록.
      MapPoint mapPoint = MapPoint.mapPointWithGeoCoord(item.latitude, item.longitude);
      poiItem.setMapPoint(mapPoint);
      //...중략
      mMapView.addPOIItem(poiItem);
      mTagItemMap.put(poiItem.getTag(), item);
  }
  //화면 이동.
  mMapView.moveCamera(CameraUpdateFactory.newMapPointBounds(mapPointBounds));
  //트랙킹모드 실행.
  mMapView.setCurrentLocationTrackingMode(MapView.CurrentLocationTrackingMode.TrackingModeOnWithoutHeadingWithoutMapMoving);
  MapPOIItem[] poiItems = mMapView.getPOIItems();
    if (poiItems.length > 0) {
        mMapView.selectPOIItem(poiItems[0], false);
    }
  }
//...중략
}

위 코드와 같이 전체적인 루틴은 입력 받은 검색 값(query)에 대한 정보를 얻기 위해 반경, 페이지 정보, 현재 사용자의 위치, api key 등을 파라미터로 넘겨주고 Searcher 객체의 OnFinishSearchListener 구현을 통해 정상 호출시에 API를 호출하고, 반환 받은 결과 값을 마커와 Balloon 형식으로 표현하는 루틴이라고 할 수 있다. 정상적으로 API 호출이 이루어지면 아래 사진처럼 나타나게 된다. seacer.jpeg

카카오 내비 연동

다음은 안전모 앱에서의 길 찾기 기능에 대해서 알아보자. 길 찾기 기능은 기존의 여러 기업에서 제공하는 길 찾기 전용 API를 요청하고, 반환받은 값을 현재 앱의 지도에 보여주는 방식으로 사용하는 방법과 uri 연결 등을 통한 파라미터만 넘겨주고 앱을 새롭게 보여주는 방식이 있다. 안전모에선 후자의 기능을 이용하였고, 안전모 앱에서 카카오 내비를 실행시키는 방식으로 동작된다.

우선, 카카오 내비와의 연동을 위해 build.gradle 파일에 아래와 같이 의존성을 추가해준다.

implementation group: 'com.kakao.sdk', name: 'kakaonavi', version: project.KAKAO_SDK_VERSION

마찬가지로 카카오 내비 SDK 연동을 위한 gradle.properties 파일에 아래와 같이 코드를 추가해준다. 안전모는 1.2.2를 사용했고, 카카오 개발자 센터에선 1.1.1 이상의 버전을 사용할 것을 권장한다.

KAKAO_SDK_VERSION=1.2.2

카카오 내비 실행

위 코드에 나온 검색 API를 통해 키워드 혹은 카테고리로 검색에 대한 검색 결과를 반환 받고 마커와 CalloutBalloon을 통해 표현하였다. 안전모에선 특정 CalloutBalloon을 클릭하고 길 찾기를 실행하면 해당 위치의 결과 값에 해당하는 위도, 경도 정보를 카카오 내비 앱에 파라미터로 함께 넘겨주면서 실행되어 카카오 내비 앱이 실행되면서 곧바로 길 안내가 시작된다.

import com.kakao.kakaonavi.*;
import net.daum.mf.map.api.*;
//...중략
private HashMap<Integer, Item> mTagItemMap = new HashMap<Integer, Item>();
//...중략
@Override
  public void onCalloutBalloonOfPOIItemTouched(MapView mapView, MapPOIItem mapPOIItem, MapPOIItem.CalloutBalloonButtonType calloutBalloonButtonType) {
    //클릭한 Balloon의 정보를 가져온다. mTagItemMap 해쉬 맵 객체의 태그를 이용해 가져온다.
    Item item = mTagItemMap.get(mapPOIItem.getTag());
    //도착지 정보를 파라미터로 넘겨준다.
    navigationDialog(item.newAddress, item.latitude, item.longitude);
  }

  private void navigationDialog(final String des, final Double lat, final Double lon){
    android.app.AlertDialog.Builder alertDialog = new android.app.AlertDialog.Builder(SearchActivity.this);
    alertDialog.setTitle(getString(R.string.direction));
    alertDialog.setMessage(getString(R.string.set_map_loc_ok));
    alertDialog.setPositiveButton(getString(R.string.start), new DialogInterface.OnClickListener() {
      public void onClick(DialogInterface dialog,int which) {
        try {
          //카카오 내비가 설치 되어 있는지 확인.
        if(KakaoNaviService.isKakaoNaviInstalled(getApplicationContext())) {
          //카카오 내비가 설치 되어 있다면 KakaoNaviParams 빌더를 통해 도착지 정보와 출발지 정보를 넘겨준다.
          com.kakao.kakaonavi.Location kakao = Destination.newBuilder(des, lon, lat).build();
          KakaoNaviParams params = KakaoNaviParams.newBuilder(kakao)
          .setNaviOptions(NaviOptions.newBuilder()
          .setCoordType(CoordType.WGS84) // WGS84로 설정해야 경위도 좌표 사용 가능.
          .setRpOption(RpOption.NO_AUTO)
          .setStartAngle(200) //시작 앵글 크기 설정.
          .setVehicleType(VehicleType.TWO_WHEEL).build()).build(); //길 안내 차종 타입 설정
          //카카오 내비앱 실행.
          KakaoNaviService.navigate(SearchActivity.this, params);
        }else {
          //카카오 내비가 설치되어 있지 않다면, 카카오 내비의 설치를 요청한다.
          Intent intent = new Intent(Intent.ACTION_VIEW,
          Uri.parse("https://play.google.com/store/apps/details?id=com.locnall.KimGiSa"));
          startActivity(intent);
          Toast.makeText(getApplicationContext(), getString(R.string.navi_install),Toast.LENGTH_LONG).show();
        }
      }catch (Exception e){
        e.printStackTrace();
        Toast.makeText(getApplicationContext(),e.toString()+getString(R.string.error_default),Toast.LENGTH_LONG).show();
      }
    }
  });
  alertDialog.setNegativeButton(getString(R.string.close), new DialogInterface.OnClickListener() {
    public void onClick(DialogInterface dialog, int which) {
      dialog.cancel();
    }
  });
  alertDialog.show();
}
//...중략

위 코드와 같이 isKakaoNaviInstalled를 통해 카카오 내비가 설치되어 있는지 확인할 수 있으며, 설치가 되지 않았을 경우를 대비해 길 찾기 기능을 실행하기 위해서 카카오 내비 설치가 필요하다는 토스트와 함께 설치 화면으로 넘어가게 된다. 카카오 내비 실행시 함께 넘겨줄 수 있는 파라미터에 대한 더 자세한 설명은 여기를 참고. seacer.jpeg

경로 저장

다음은 전 포스팅에서 정리했던 안전모의 위치 측정 기능을 이용해 GPSService를 응용한 경로 저장에 대한 부분이다. 주행 시작 버튼을 클릭하고 위치를 받아오기 시작한 후, 주행 종료를 누를 때까지 위치를 지속적으로 받아오고, 받아온 데이터를 실시간으로 SQLite3 로컬 DB에 저장하게 된다. (SQLite3는 안드로이드에 기본적으로 사용 가능한 내장 로컬 DB이므로 특별한 설정이 필요하지 않다. SQLite3는 Realm과 자주 비교되곤 하는데, Realm과 관련된 포스팅도 진행해보는 걸로~)

레이아웃 구성

<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    android:orientation="horizontal"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:layout_alignParentBottom="true">
    <LinearLayout
    android:id="@+id/first"
    android:layout_width="match_parent"
    android:layout_height="wrap_content">

    <TextView
            android:id="@+id/titleStartAddr"
            android:layout_width="0dp"
            android:layout_height="wrap_content"
            android:layout_weight="0.5"
            android:text="@string/startAddr"
            android:textStyle="bold"
            android:gravity="center_horizontal"
            android:textSize="15sp"
            android:textColor="@color/blue_dark"/>
    <TextView
        android:id="@+id/titleEndAddr"
        android:layout_width="0dp"
        android:textStyle="bold"
        android:gravity="center_horizontal"
        android:layout_height="wrap_content"
        android:layout_weight="0.5"
        android:text="@string/endAddr"
        android:textSize="15sp"
        android:textColor="@color/blue_dark" />

    <TextView
        android:id="@+id/titleStartTime"
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        android:layout_weight="1"
        android:gravity="center_horizontal"
        android:text="@string/startTime"
        android:textSize="15sp"
        android:textStyle="bold"
        android:textColor="@color/blue_dark" />

    <TextView
        android:id="@+id/titleEndTime"
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        android:layout_weight="1"
        android:gravity="center_horizontal"
        android:text="@string/endTime"
        android:textSize="15sp"
        android:textStyle="bold"
        android:textColor="@color/blue_dark" />
</LinearLayout>
    <LinearLayout
        android:id="@+id/second"
        android:layout_below="@id/first"
        android:layout_width="match_parent"
        android:layout_height="wrap_content">
    <TextView
        android:id="@+id/startAddr"
        android:text=""
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_weight="0.4"
        android:textSize="12sp"
        android:layout_gravity="center_horizontal"
        android:textColor="@android:color/black"/>
    <TextView
        android:id="@+id/endAddr"
        android:text=""
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_weight="0.4"
        android:layout_gravity="center_horizontal"
        android:textSize="12sp"
        android:textColor="@android:color/black"/>
    <TextView
        android:id="@+id/startTime"
        android:text=""
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_weight="0.3"
        android:textSize="10sp"
        android:layout_gravity="center_horizontal"
        android:textColor="@android:color/black"/>
    <TextView
        android:id="@+id/endTime"
        android:text=""
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_weight="0.3"
        android:textSize="10sp"
        android:layout_gravity="center_horizontal"
        android:textColor="@android:color/black"/>
    </LinearLayout>
    <View
        android:id="@+id/separators"
        android:layout_below="@+id/second"
        android:layout_width="match_parent"
        android:layout_height="1dp"
        android:background="@color/red_dark">
    </View>
    <LinearLayout
    android:id="@+id/third"
        android:layout_below="@+id/separators"
        android:layout_width="match_parent"
        android:layout_height="wrap_content">
    <TextView
        android:id="@+id/titleAvg"
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        android:layout_gravity="center_horizontal"
        android:text="@string/average_speed"
        android:layout_weight="0.7"
        android:textSize="15sp"
        android:textColor="@color/blue_dark" />

    <TextView
        android:id="@+id/titleCaloire"
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        android:layout_weight="0.7"
        android:layout_gravity="center_horizontal"
        android:text="@string/calorie"
        android:textSize="15sp"
        android:textColor="@color/blue_dark" />

    <TextView
        android:id="@+id/titleDistance"
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        android:layout_weight="0.7"
        android:layout_gravity="center_horizontal"
        android:text="@string/distance"
        android:textSize="15sp"
        android:textColor="@color/blue_dark" />

    <TextView
        android:id="@+id/titleTemp"
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        android:layout_weight="0.5"
        android:layout_gravity="center_horizontal"
        android:text="@string/temp"
        android:textSize="15sp"
        android:textColor="@color/blue_dark" />
    <TextView
        android:id="@+id/titleWet"
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        android:layout_weight="0.5"
        android:layout_gravity="center_horizontal"
        android:text="@string/wet"
        android:textSize="15sp"
        android:textColor="@color/blue_dark" />
    </LinearLayout>
    <!-- ...중략 -->
</RelativeLayout>

위 레이아웃 xml 코드는 안전모의 경로저장을 위헤 사용된 커스텀 레이아웃이다. 제일 바깥쪽 레이아웃으로 RelativeLayout을 이용하고, 원하는 레이아웃 구성을 위해 LinearLayout 안에 TextView와 View를 통해 구성되어진 것을 확인할 수 있다.
레이아웃

위 코드를 통해 위 사진과 같이 레이아웃을 구성할 수 있다.

Cursor Adapter

레이아웃을 구성하였다면, 실제 자바 코드를 통해 SQLite3 로컬 DB에 저장된 데이터를 가시적으로 출력하는 폼이 필요하다. 우선 CursorAdapter를 상속받는 클래스를 구현해서 실제 레이아웃에 출력될 DB 정보를 맵핑하고, TrackDBhelper 클래스를 통해 DB 정보를 핸들링한다.

public class TrackAdapter extends CursorAdapter{

    private Context mContext;
    private int startAddr;
    private int endAddr;
    private int startTime;
    private int endTime;
    private int avgSpeed;
    private int calorie;
    private int distance;
    private int temps;
    private int wets;

    public TrackAdapter(Context context, Cursor cursor){
        super(context, cursor, 0);
        mContext = context;
        startAddr = cursor.getColumnIndex("startAddr");
        endAddr = cursor.getColumnIndex("endAddr");
        startTime = cursor.getColumnIndex("startTime");
        endTime = cursor.getColumnIndex("endTime");
        avgSpeed = cursor.getColumnIndex("avgSpeed");
        calorie = cursor.getColumnIndex("calorie");
        distance = cursor.getColumnIndex("distance");
        temps = cursor.getColumnIndex("temp");
        wets = cursor.getColumnIndex("wet");
    }
    //newView 메서드를 오버라이딩 해서 원하는 커스텀 레이아웃을 구현한다.
    @Override
    public View newView(Context context, Cursor cursor, ViewGroup parent){
        return LayoutInflater.from(context).inflate(R.layout.custom_listview, parent, false);
    }
    //bindView 메서드를 오버라이딩해서 DB에 저장된 데이터를 원하는 포멧에 맞게 출력한다.
    @Override
    public void bindView(final View view, final Context context, final Cursor cursor){
        //포멧팅을 통해 원하는 포멧으로 출력한다. 포멧팅을 설정하지 않으면, 출력 값을 표현할 때 소숫점이 굉장히 길어지는 현상?을 마주 할 수 있다.
        DecimalFormat decimalFormat = new DecimalFormat("#.###");
        //DB에서 값을 읽어오기
        final String sttime = cursor.getString(startTime);
        final String endtime = cursor.getString(endTime);
        final String stAddr = cursor.getString(startAddr);
        final String edAddr = cursor.getString(endAddr);
        double speed = cursor.getDouble(avgSpeed);
        double dis = cursor.getDouble(distance);
        double cal = cursor.getDouble(calorie);
        double temp = cursor.getDouble(temps);
        double wet = cursor.getDouble(wets);
        //레이아웃 구성에 맞게 TextView와 매칭한다.
        TextView stTime = (TextView) view.findViewById(R.id.startTime);
        TextView edTime = (TextView) view.findViewById(R.id.endTime);
        TextView startA = (TextView) view.findViewById(R.id.startAddr);
        TextView endA = (TextView) view.findViewById(R.id.endAddr);
        TextView avgsp = (TextView) view.findViewById(R.id.Avg);
        TextView distanceView = (TextView) view.findViewById(R.id.Distance);
        TextView calorieView = (TextView) view.findViewById(R.id.Calorie);
        TextView tempView = (TextView) view.findViewById(R.id.Temp);
        TextView wetView = (TextView) view.findViewById(R.id.Wet);

        //출력 폼 설정 코드 작성.
                stTime.setText(sttime);
                edTime.setText(endtime);
                startA.setText(stAddr);
                endA.setText(edAddr);
                avgsp.setText(String.valueOf(decimalFormat.format(speed))+"km/h");
            if (cal/1000f<1) {
                calorieView.setText(String.valueOf(decimalFormat.format(cal))+"cal");
            }else {
                calorieView.setText(String.valueOf(decimalFormat.format(cal/1000f))+"kcal");
            }
            if (dis/1000f<1) {
                distanceView.setText(String.valueOf(decimalFormat.format(dis))+"m");
            }else{
                distanceView.setText(String.valueOf(decimalFormat.format(dis/1000f))+"km");
            }
                tempView.setText(String.valueOf(decimalFormat.format(temp))+"°C");
                wetView.setText(String.valueOf(decimalFormat.format(wet))+"%");
    }
}

DBHelper

위의 코드처럼 CursorAdapter를 구현하고, 실제 SQLite3 DB와 맵핑할 위치와 관련된 일련의 정보를 구성할 DBHelper 클래스를 구현한다.

import android.content.ContentValues;
import android.content.Context;
import android.database.Cursor;
import android.database.SQLException;
import android.database.sqlite.SQLiteDatabase;
import android.database.sqlite.SQLiteOpenHelper;
import java.util.*;
//...중략
public class TrackDBhelper{
  private static final String TAG ="TrackDBhelper";
    //DB에 저장될 컬럼 구성.
      public static final String KEY_ROWID = "_id";
      public static final String KEY_START_ADDR = "startAddr";
      public static final String KEY_END_ADDR = "endAddr";
      public static final String KEY_START_TIME = "startTime";
      public static final String KEY_END_TIME = "endTime";
      public static final String KEY_AVG_SPEED = "avgSpeed";
      public static final String KEY_CALORIE = "calorie";
      public static final String KEY_DISTANCE = "distance";
      public static final String KEY_TEMP = "temp";
      public static final String KEY_WET = "wet";
      public static final String KEY_RUNNING_TIME = "runtime";
      public static final String KEY_COORDINATE_X = "_x";
      public static final String KEY_COORDINATE_Y = "_y";

      private DatabaseHelper mDbHelper;
      private SQLiteDatabase mDb;

      private static final String DATABASE_NAME = "trackdb";
      private static final String DATABASE_TABLE = "track";
      private static final String DATABASE_TABLE_MAP = "map";
      private static final int DATABASE_VERSION = 1;
      //커스텀 레이아웃에 출력할 정보를 저장할 컬럼 구성.
      private static final String DATABASE_CREATE =
              "CREATE TABLE "+ DATABASE_TABLE +" ("
                      +KEY_ROWID+" INTEGER PRIMARY KEY AUTOINCREMENT, "
                      +KEY_START_ADDR+" TEXT,"
                      +KEY_START_TIME+" TEXT,"
                      +KEY_END_ADDR+" TEXT,"
                      +KEY_END_TIME+" TEXT,"
                      +KEY_AVG_SPEED+" TEXT,"
                      +KEY_CALORIE+ " REAL,"
                      +KEY_DISTANCE+" REAL,"
                      +KEY_TEMP+" TEXT,"
                      +KEY_WET+" TEXT"+ ");";

      //지도에 출력할 위치 정보를 저장할 컬럼 구성.
      private static final String DATABASE_CREATE_MAP =
              "CREATE TABLE "+ DATABASE_TABLE_MAP+" ("
                      + KEY_ROWID + " INTEGER PRIMARY KEY AUTOINCREMENT, "
                      + KEY_START_TIME + " TEXT,"
                      + KEY_END_TIME + " TEXT,"
                      + KEY_RUNNING_TIME + " TEXT,"
                      + KEY_COORDINATE_X+" REAL,"
                      + KEY_COORDINATE_Y+" REAL"+ ");";

      private List<TrackPoint> trackPoints;
      private final Context mCtx;

      private static class DatabaseHelper extends SQLiteOpenHelper {

        DatabaseHelper(Context context) {
            super(context, DATABASE_NAME, null, DATABASE_VERSION);
        }
        @Override
        public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) {
        }
        //지도에 출력할 정보와 데이터를 저장할 테이블을 CREATE 한다.
        @Override
        public void onCreate(SQLiteDatabase db) {
            db.execSQL(DATABASE_CREATE);
            db.execSQL(DATABASE_CREATE_MAP);
        }
    }

    public TrackDBhelper(Context context){
        this.mCtx =context;
    }

    public TrackDBhelper open() throws SQLException {
        mDbHelper = new DatabaseHelper(mCtx);
        mDb = mDbHelper.getWritableDatabase();
        return this;
    }

    public void close() {
        mDbHelper.close();
    }
    //종료할 때 출발 주소<>시간, 도착 주소<>시간, 평속, 칼로리, 거리, 온도, 습도 저장
    public long trackDBallFetch(String startAddr, String endAddr, String startTime, String endTime, String avgSpeed, double calroie, double distance, String temp, String wet){
        ContentValues contentValues = new ContentValues();
        contentValues.put(KEY_START_ADDR, startAddr);
        contentValues.put(KEY_END_ADDR, endAddr);
        contentValues.put(KEY_START_TIME, startTime);
        contentValues.put(KEY_END_TIME, endTime);
        contentValues.put(KEY_AVG_SPEED, avgSpeed);
        contentValues.put(KEY_CALORIE, calroie);
        contentValues.put(KEY_DISTANCE, distance);
        contentValues.put(KEY_TEMP, temp);
        contentValues.put(KEY_WET, wet);
        return mDb.insert(DATABASE_TABLE, null, contentValues);
    }

    //시작할 때 위치, 시간 저장.
    public long trackDBlocationStart(String startTime, double lat, double lng){
        ContentValues contentValues = new ContentValues();
        contentValues.put(KEY_START_TIME, startTime);
        contentValues.put(KEY_COORDINATE_X, lat);
        contentValues.put(KEY_COORDINATE_Y, lng);
        return mDb.insert(DATABASE_TABLE_MAP, null, contentValues);
    }

    //출발 시간 between 도착 시간 사이에 있는 값을 가져오기.
    public List<TrackPoint> fetchBetweenTime(String start, String end){
        trackPoints = new ArrayList<>();
        mDbHelper = new DatabaseHelper(mCtx);
        mDb =  mDbHelper.getReadableDatabase();
        Cursor res = mDb.rawQuery("select * from " + DATABASE_TABLE_MAP+" where "+ KEY_RUNNING_TIME +" between "+"'"+start+"'"+" and "+"'"+end+"'", null);
        res.moveToFirst();
        while (!res.isAfterLast()) {
            double lat = res.getDouble(res.getColumnIndex(KEY_COORDINATE_X));
            double lng = res.getDouble(res.getColumnIndex(KEY_COORDINATE_Y));
            trackPoints.add(new TrackPoint(lat, lng));
            res.moveToNext();
        }
        if (res.getCount()==0){
            return null;
        }
        res.close();
        return trackPoints;
    }
    //...중략
}

위와 같이 DBHelper 클래스 구현을 완료하면, 경로를 저장할 클래스 구현이 완료 됐으니 실제 Activity 클래스에 값을 뿌리는 코드를 작성하면 된다.

지도 API 호출

//DB에 저장된 포인트를 경위도 형식으로 출력하기 위한 TrackPoint VO 모델 클래스
public class TrackPoint {
    private double lat;
    private double lng;

    public TrackPoint(double lat, double lng){
        this.lat = lat;
        this.lng = lng;
    }
    public double getLat(){
        return lat;
    }
    public double getLng(){
        return lng;
    }
}

먼저, 위와 같이 위치 정보를 단순 객체화 하기 위한 TrackPoint VO 모델 클래스를 작성한다. 여기까지 완료되면 실제 TrackPoint 클래스를 사용할 Activity를 구현하면 된다. 안전모에선 저장된 데이터를 보여주기 위해 앞서 구현한 레이아웃 형식을 ListView 형식으로 출력한다. 남은 단계는 안드로이드 ListView의 setOnItemLongClickListener를 구현해서 저장된 특정 데이터에 접근하는 일이다.

//...중략
public class TrackActivity extends AppCompatActivity {
  listView = (ListView)findViewById(R.id.trackListview);
  final TrackDBhelper trackDBhelper = new TrackDBhelper(this);
  //...중략
  listView.setOnItemLongClickListener(new AdapterView.OnItemLongClickListener() {
            @Override
            public boolean onItemLongClick(AdapterView<?> parent, View view, final int position, final long id) {
                android.app.AlertDialog.Builder alertDialog = new android.app.AlertDialog.Builder(TrackActivity.this);
                alertDialog.setTitle(getString(R.string.category));
                alertDialog.setItems(new CharSequence[]{getString(R.string.remove), getString(R.string.show_map), getString(R.string.about_pageSHARE), getString(R.string.close)},
                        new DialogInterface.OnClickListener() {
                            @Override
                            //선택 조건에 맞는 코드를 작성한다.
                            public void onClick(DialogInterface dialog, int which) {
                                switch (which){
                                    case 0:     
                                    //...중략                           
                                        break;
                                    case 1:
                                    try {
                                        Cursor cursor1 = (Cursor) trackAdapter.getItem(position);
                                        String startTime = cursor1.getString(cursor1.getColumnIndex(TrackDBhelper.KEY_START_TIME));
                                        String endTime = cursor1.getString(cursor1.getColumnIndex(TrackDBhelper.KEY_END_TIME));

                                        Intent intent = new Intent(TrackActivity.this, MapActivity.class);
                                        //선택된 List의 position을 통해 얻어온 정보를 기준으로 DB에서 주행 시작 시간과 종료시간을 intent로 넘겨준다.
                                        intent.putExtra("startTime", startTime);
                                        intent.putExtra("endTime", endTime);
                                        startActivity(intent);

                                      }catch (Exception e){
                                          e.printStackTrace();
                                          Toast.makeText(getApplicationContext(), getString(R.string.error_default)+e, Toast.LENGTH_SHORT).show();
                                      }
                                        break;
                                    case 2:
                                      //...중략
                                      break;
                                    case 3:
                                        dialog.cancel();
                                      break;
                                }
                            }
                        });
                alertDialog.create().show();
                return false;
            }
        });
    }
    //...중략
}

위와 같이 코드를 작성하면 아래 사진과 같이 조건에 맞는 해당 List의 특정 값 출력을 구현할 수 있다. 이제 남은 것은 선택된 특정 List의 값을 불러와 다음 지도에 출력해서 가시화 하는 일이다. 얄루! 옵션 선택

import net.daum.mf.map.api.*;
//...중략
public class MapActivity extends FragmentActivity implements
MapView.MapViewEventListener,
MapView.POIItemEventListener{
  //받아온 데이터가 null 값이 아니라면.
  if (trackPointList!=null) {
             //DB에 저장된 시간을 기준으로 첫 번째 저장된 포인트 정보를 통해 출발 위치를 지정한다.
             TrackPoint trackPoint = trackPointList.get(0);
             double startLat = trackPoint.getLat();
             double startLng = trackPoint.getLng();
             String startTitle = getString(R.string.start_positon);
             //출발지 지정.
             MapPoint startMarkerPoint = mapPointWithGeoCoord(startLat, startLng);
             mapPOIItem = new MapPOIItem();
             mapPOIItem.setItemName(startTitle);
             mapPOIItem.setTag(0);
             mapPOIItem.setMapPoint(startMarkerPoint);
             mapPOIItem.setMarkerType(MapPOIItem.MarkerType.CustomImage);
             //다음 SDK에서 지원하는 커스텀 출발지 마커 출력.
             mapPOIItem.setCustomImageResourceId(R.drawable.custom_poi_marker_start);
             mapPOIItem.setCustomImageAutoscale(false);
             mapPOIItem.setCustomImageAnchor(0.5f, 1.0f);
             mapView.addPOIItem(mapPOIItem);
             try {
                 runOnUiThread(new Runnable() {
                     @Override
                     public void run() {
                         //시간 정보 비교를 통해 해당 List의 저장된 데이터를 불러온다.
                         trackPointList = trackDBhelper.fetchBetweenTime(starts, ends);
                         mapPolyline = new MapPolyline();
                         if (trackPointList != null && trackPointList.size() > 0) {
                            //TrackPoint 객체에 저장된 데이터(POI)를 하나하나 불러와서 저장하고 반복문을 통해 위치 정보가 저장된 포인트(POI)를 하나하나 연결하여 PolyLine을 그려준다.
                             for (i = 0; i < trackPointList.size()-1; i++) {
                                 TrackPoint obj = trackPointList.get(i);
                                 mapPolyline.setLineColor(Color.argb(128, 50, 0, 255));
                                 mapPolyline.addPoint(MapPoint.mapPointWithGeoCoord(obj.getLat(), obj.getLng()));
                                 mapView.addPolyline(mapPolyline);
                             }
                         }
                     }
                 });
                 Thread.sleep(500);
             } catch (InterruptedException e) {
                 e.printStackTrace();
             }
         //출발지 지정과 마찬가지로 도착지 지정 코드를 여기에 작성한다.
         //...중략
       }else {
          //정상적으로 데이터를 받아오지 못한 경우.
       }
    //해당 액티비티가 Destory 될 때 기존에 출력된 정보를 초기화 한다.
    @Override
    public void onDestroy() {
        super.onDestroy();
        mapView.removeAllPOIItems();
        mapView.removeAllPolylines();
    }
//...중략
}

정상적으로 PolyLine이 그려지면 아래 사진과 같이 저장된 경로를 다음 지도에 출력이 가능하다. 얄루!!! 지도 API 경로

이렇게 다음카카오 API 및 경로 저장 부분에 대해 정리해보았다. 리팩토링은 너무 어렵지만 새로운 이슈가 계속해서 생겨나니 계속해서 진행해보는 걸로 해야겠다. 다음 포스팅에선 안전모의 꽃이라고 할 수 있는 넘어짐 감지 기능에 대해서 정리하는 걸로~ 문서 작업은 역시 힘들다… 얼른 자야지~