오랜만에 안전모 프로젝트 리뷰~ 그동안 이것저것 할 일이 많아서? 나름대로 의미 있는 이유라고 자기 위안을 삼으며.. 새로운 안전모 프로젝트 리뷰를 작성하는 걸로~ 지난 포스팅에 이어서 이번 포스팅에선 안드로이드 스마트폰에 내장된 GPS 수신기를 이용한 위치 측정 부분 및 TTS 엔진을 이용한 음성 안내 부분에 대해 정리해보자.

위치 측정을 위한 준비

우선, 안드로이드 마시멜로우 이상 버전부터 사용자가 위치 측정이 필요한 특정 기능을 실행할 때, 동적으로 권한 동의 요청을 해야한다. 모든 위치 기반 서비스를 제공하는 앱에선 이 위치 권한 동의가 필수적이다. 사용자에게 동적으로 권한을 요청하고, 요청 거절, 동의 후 거절 등 사용자의 선택에 따른 여러 처리를 해줘야 하지만, 여기선 기능 실행 시 사용자가 정상적으로 권한에 동의한다고 가정하자.

AndroidManifest.xml 권한 설정

위치 측정을 위해서 안드로이드 매니페스트 파일에 위치 관련 권한 코드가 필요하다. 일단, 인터넷 권한은 모든 앱에서 필수적으로 사용되는 권한이므로 보일러 플레이트 코드?처럼 붙여주고, 그 밑엔 각각 Network provider와 GPS provider를 사용하기 위한 권한 동의 요청을 위해 작성한 코드라고 생각하면 되겠다.

<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />

Activity.java 권한 설정

매니페스트 파일에 사용할 위치 권한 설정을 작성했다면, 실제 사용자에게 애플리케이션의 권한 동의를 요청해야 한다. 동적으로 사용자의 스마트폰에서 위치를 측정할 필요가 있는 UI가 구성되는 Activity 클래스에 권한 관련 자바 코드를 작성한다. 권한 요청 동의 화면을 커스텀해서 사용할 수 있지만, 아래 코드는 AppCompatActivity 클래스에서 기본적으로 제공하는 requestPermissions 메서드를 통해 권한 요청을 하는 예이다.

public class PermissionActivity extends AppCompatActivity {
    @BindView(R.id.button) Button button;

    int PERMISSION_ALL = 1;
    String[] PERMISSIONS = {android.Manifest.permission.ACCESS_COARSE_LOCATION, android.Manifest.permission.ACCESS_FINE_LOCATION};

    //...중략

    @OnClick(R.id.buttons) void isSuccessPermission() {
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
            //어플리케이션을 처음 실행했을 시에, 권한 확인.
            if (ActivityCompat.checkSelfPermission(this, android.Manifest.permission.ACCESS_COARSE_LOCATION) != PackageManager.PERMISSION_GRANTED &&
            ActivityCompat.checkSelfPermission(this, android.Manifest.permission.ACCESS_FINE_LOCATION) != PackageManager.PERMISSION_GRANTED) {
            //위치 권한 동의 요청.
            ActivityCompat.requestPermissions(this, PERMISSIONS, PERMISSION_ALL);
          }
        }
      }
}

안전모 위치 측정

우선 위치 측정 코드를 살펴보기 전에, 서비스 클래스에 대한 이해가 필요하니 간단하게 정리해보자. 위치 측정이란 기능은 특성상 한 번 실행하면, 사용자가 그 기능을 종료하기 전까진 예외 없이 지속적으로 작동해야 하는 기능이다. 여기서 예외란, 여러가지 경우가 있겠지만 크게 앱의 화면 전환을 예로 들 수 있다. 앱 실행 도중 다른 앱의 작동 등에 화면 전환이 일어나면 액티비티 생명주기에 맞게 onPause, onStop 등의 메서드가 호출된다. 이 예외 상황에도 변함 없이 동작하도록 만들 수 있는 클래스가 바로 Service 클래스를 상속받는 클래스이다. 안드로이드 서비스에 대한 더 자세한 설명은 여기를 참고.

  • 서비스 클래스는 UI 부분과는 별개로 백그라운드에서 실행되므로 위치 측정 결과를 보여줄 Activity 클래스는 따로 구성해야 한다.

안전모는 위치 측정을 통해 최고 속도, 현재 속도, 평균 속도, 이동 거리 등 다양한 정보를 계산하게 되는데, 이러한 정보를 저장하고 표현할 모델 클래스가 필요하다. 아래는 그러한 역할을 하는 Data 클래스의 예이다. 아래 코드에서 문자열 표현을 위해 SpannableString 클래스를 사용한다.

//원하는 형태로 텍스트를 출력하기 위해 import
import android.text.SpannableString;
import android.text.style.RelativeSizeSpan;
import java.util.Locale;

//위치를 측정을 통해 저장할 DTO 모델 클래스.
public class Data {
  // 필드 선언.
  private boolean isRunning;
  private long time;
  private long timeStopped;
  private double distanceKm;
  private double distanceM;
  private double maxSpeed;
  private double curSpeed;

  // Activity에서 구현할 GPS 업데이트 인터페이스 선언.
  public interface onGpsServiceUpdate {
        public void update();
    }
  // GPS 정보를 업데이트할 Setter.
  public void setOnGpsServiceUpdate(onGpsServiceUpdate onGpsServiceUpdate) {
        this.onGpsServiceUpdate = onGpsServiceUpdate;
  }
  public void update() {
        onGpsServiceUpdate.update();
  }
  // 생성자 기본 값 초기화.
  public Data() {
        isRunning = false;
        distanceKm = 0;
        distanceM = 0;
        curSpeed = 0;
        maxSpeed = 0;
        timeStopped = 0;
  }
  // GPS 업데이트를 위한 생성자 오버로딩.
  public Data(onGpsServiceUpdate onGpsServiceUpdate) {
        this();
        setOnGpsServiceUpdate(onGpsServiceUpdate);
  }
  // 계산된 거리 추가.
  public void addDistance(double distance) {
        distanceM = distanceM + distance;
        distanceKm = distanceM / 1000f;
  }
  // SpannableString 타입으로 계산된 거리 반환.
  public SpannableString getDistance() {
      SpannableString s;
      //m, km 단위 환산을 통해 다르게 표현.
      if (distanceKm < 1) {
          s = new SpannableString(String.format(Locale.KOREA, "%.0f", distanceM) + "m");
          s.setSpan(new RelativeSizeSpan(0.5f), s.length() - 1, s.length(), 0);
      }
      else {
          s = new SpannableString(String.format(Locale.KOREA, "%.3f", distanceKm) + "km");
          s.setSpan(new RelativeSizeSpan(0.5f), s.length()-2, s.length(), 0);
      }
      return s;
  }
  // SpannableString 타입으로 계산된 최고 속도 반환.
  public SpannableString getMaxSpeed() {
       SpannableString s = new SpannableString(String.format(Locale.KOREA, "%.0f", maxSpeed) + "km/h");
       //setSpan 메서드의 인자 (Object what, int start, int end, int flags).
       s.setSpan(new RelativeSizeSpan(0.5f), s.length() - 4, s.length(), 0);
       return s;
   }
   // Getter, Setter 중략
   //...중략

}

위에서 위치 측정값을 저장할 모델 클래스를 선언하였다. 이제 실제로 Data 인스턴스의 값을 저장할 수 있도록 서비스 클래스를 구현하면 된다. 안전모에선 사용자의 선택에 따라 GPS 수신 감도를 설정하거나, 수신을 통한 계산 결과 값을 다르게 표현할 수 있도록 했는데, 이 부분은 아래 코드의 SharedPreferences 클래스 부분을 통해 확일 할 수 있다. 이 부분은 환경 설정 및 도움말을 포스팅할 때 더 자세히 알아보는 걸로~

// GPSservice 클래스.
import android.app.Service;
import android.location.Location;
import android.location.LocationListener;
import android.location.LocationManager;
import android.content.SharedPreferences;
//...중략

//서비스 클래스를 상속받고, 위치 측정을 위해 LocationListener 인터페이스를 implements 해서 구현한다.
public class GPSService extends Service implements LocationListener {
  private LocationManager mLocationManager; // LocationManager 객체.
  private SharedPreferences sharedPreferences; //Setting 결과를 반영할 SharedPreferences 객체.
  private Data data; // Model 클래스.
  private Location lastlocation = new Location("last"); //위치 비교를 위한 Location 객체.
  private double lastLon = 0; // 위도를 나타낼 변수.
  private double lastLat = 0; // 경도를 나타낼 변수.
  public PendingIntent contentIntent; // 서비스 실행을 표시하기 위한 PendingIntent 객체.
  private Handler handler; // 실시간으로 위치 설정 권한 확인을 위한 Handler.

  //...중략

  //위치 측정을 위해 필요한 객체 초기화 등에 작업을 onCreate 메서드에 작성한다.
  @Override
  public void onCreate() {
    // 설정 값의 변화를 반영하기 위한 객체.
    sharedPreferences = PreferenceManager.getDefaultSharedPreferences(getApplicationContext());
    // 서비스 실행시 실행되는 인텐트
    Intent notificationIntent = new Intent(this, MainActivity.class);
    // 노티피케이션바가 최상위 스택에 항상 있을수 있도록 선언한다.
    notificationIntent.setFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP | Intent.FLAG_ACTIVITY_SINGLE_TOP);
    contentIntent = PendingIntent.getActivity(this, 0, notificationIntent, 0);

    updateNotification(false);

    // GPS 수신기를 통해 위치를 받아올 LocationManager 객체.
    mLocationManager = (LocationManager) this.getSystemService(Context.LOCATION_SERVICE);
    // 설정 선택에 따른 GPS 수신 감도에 따른 변화를 위해 선언된 임시 변수.
    String gpsValue = sharedPreferences.getString("gps_level", "default");
    // 위치 측정 기능을 사용하기 위해선 GPS_PROVIDER가 사용 가능 상태인지 확인하고, 권한 설정 확인 한 번 더 필요하다.
    if (mLocationManager.isProviderEnabled(LocationManager.GPS_PROVIDER)&&
    ActivityCompat.checkSelfPermission(getApplicationContext(),
    android.Manifest.permission.ACCESS_COARSE_LOCATION) != PackageManager.PERMISSION_DENIED) {
      // 설정 값에 따른 requestLocationUpdates 메서드의 인자를 다르게 넘겨준다.
      // requestLocationUpdates 메서드의 인자 (String provider, long minTime, float minDistance, LocationListener listener)
      switch (gpsValue) {
               case "default":
                   mLocationManager.requestLocationUpdates(LocationManager.GPS_PROVIDER, 1000, 0, this);
                   break;
               case "high":
                   mLocationManager.requestLocationUpdates(LocationManager.GPS_PROVIDER, 500, 0, this);
                   break;
               case "low":
                   mLocationManager.requestLocationUpdates(LocationManager.GPS_PROVIDER, 7000, 0, this);
                   break;
               case "middle":
                   mLocationManager.requestLocationUpdates(LocationManager.GPS_PROVIDER, 4000, 0, this);
                   break;
           }
           //...중략
    }
    // LocationListener 인터페이스의 메서드들을 오버라이딩해서 필요한 부분을 구현해야 한다.
    // onLocationChanged는 위치 변화가 생기면 자동으로 호출되는 메서드이다.
    @Override
    public void onLocationChanged(Location location){
        // MainActivity의 UI 변화를 판단한다.
        data = MainActivity.getData();

        if (data.isRunning()) {
           double currentLat = location.getLatitude();
           double currentLon = location.getLongitude();

            if (data.isFirstTime()) {
                lastLat = currentLat;
                lastLon = currentLon;
                data.setFirstTime(false);
        }
        //
        lastlocation.setLatitude(lastLat);
        lastlocation.setLongitude(lastLon);
        // 이동한 위치를 마지막에 계산된 위치와 비교하여 거리를 계산한다.
        double distance = lastlocation.distanceTo(location);

        // 위치 계산 정확도와 거리를 비교해서 정확도가 더 좋으면, 거리를 추가하고 현재 위치를 변화시켜준다.
        if (location.getAccuracy() < distance) {
             data.addDistance(distance);
             lastLat = currentLat;
             lastLon = currentLon;
        }
        // 현재 속도를 계산할 수 있으면, 현재 속도를 Set 한다.
        if (location.hasSpeed()) {
             data.setCurSpeed(location.getSpeed() * 3.6);
             //m/s 단위로 현재 속도를 반환한다.
             if (location.getSpeed() == 0) {
                 new isStillStopped().execute();
             }
        }
        // GPS 수신 정보를 통해 획득한 정보를 업데이트 한다.
        data.update();
        // 노티피케이션 바를 띄운다.
        updateNotification(true);
        // ...중략
    }
    @Override
    public void onProviderDisabled(String provider) {
    }

    @Override
    public void onProviderEnabled(String provider) {
    }

    @Override
    public void onStatusChanged(String provider, int status, Bundle extras) {
    }
    // 노티피케이션 바를 실행해서 값을 출력한다.
    public void updateNotification(boolean asData) {
        Notification.Builder builder = new Notification.Builder(getBaseContext())
                .setContentTitle(getString(R.string.running))
                .setSmallIcon(R.drawable.mainlogo2)
                .setContentIntent(contentIntent);

        if (asData) { // 출력 가능한 데이터가 있을 경우.   
            builder.setContentText(String.format(getString(R.string.notification), data.getCalorieMeter(), data.getDistance()));
        } else { // 출력 가능한 데이터가 없을 경우.   
            builder.setContentText(String.format(getString(R.string.notification), '-', '-'));
        }
        Notification notification = builder.build();
        // 아래 메서드를 통해 사용자의 화면 전환에도 데이터를 계속해서 출력할 수 있다.
        startForeground(R.string.noti_id, notification);
    }

    /*
    아래 코드는 서비스 클래스를 상속받고 필수적으로 오버 라이딩해서 구현해야 하는 메서드이다.
    여기선, 위치 권한이 동의되어 있는지 확인하고, 동의되어 있지 않을 경우, 권한 동의가 필요한 토스트를 띄우게 된다.
    */
    @Override
    public int onStartCommand(Intent intent, int flags, int startId) {
        context = getApplicationContext();
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
            if (ContextCompat.checkSelfPermission(getApplicationContext(), android.Manifest.permission.ACCESS_COARSE_LOCATION) != PackageManager.PERMISSION_GRANTED
                    && checkSelfPermission(android.Manifest.permission.ACCESS_FINE_LOCATION) != PackageManager.PERMISSION_GRANTED) {
                handler.post(new Runnable() {
                    @Override
                    public void run() {
                        Toast.makeText(GPSService.this.getApplicationContext(), getString(R.string.permission_error_app_location), Toast.LENGTH_SHORT).show();
                    }
                });
            }
        }
        return START_STICKY;
    }

    @Override
    public IBinder onBind(Intent intent) {
        return null;
    }

    @Override
    public boolean onUnbind(Intent intent) {
        return super.onUnbind(intent);
    }

    // 서비스가 종료될 때, removeUpdates 메서드를 호출해서 LocationManager의 자원을 해제해야 한다.
    @Override
    public void onDestroy() {
        super.onDestroy();
        mLocationManager.removeUpdates(this);
    }
    //...중략
}

위와 같이 Service 코드 작성이 완료되면 AndroidManifest 파일에 아래와 같이 해당 서비스를 사용할 수 있도록 작성해야 한다.

<service
    android:name=".Service.GPSService"
    android:enabled="true"
    android:exported="false" />

안전모 위치 출력

눈여겨볼 것은 MainActivity에서 위 서비스 클래스와의 상호작용을 통해 값을 표현할 수 있게 된다는 점이다. 사용자 UI 변화에 따른 값을 Activity에서 직접적으로 핸들링한다면, 백그라운드 작업은 Service 클래스에서 작업하고 값을 저장한다고 할 수 있다. 데이터가 출력될 MainActivity 클래스의 실제 코드를 간단하게 살펴보자.

import android.widget.Chronometer;
import android.location.Location;
import android.location.LocationListener;
import app.cap.ajm.Model.Data;
import app.cap.ajm.Util.AJMapp;
import com.gc.materialdesign.views.ProgressBarCircularIndeterminate;
import com.melnykov.fab.FloatingActionButton;
import android.view.KeyEvent;
import com.google.gson.Gson;
import butterknife.ButterKnife;
//...중략

// MainActivity 위치 측정값을 출력할 클래스
public class MainActivity extends AppCompatActivity implements LocationListener {
  @BindView(R.id.fab) FloatingActionButton fab;
  @BindView(R.id.refresh) FloatingActionButton refresh;
  @BindView(R.id.holder) FloatingActionButton holder;
  @BindView(R.id.currentSpeed) TextView currentSpeed;
  @BindView(R.id.maxSpeed) TextView maxSpeed;
  @BindView(R.id.averageSpeed) TextView averageSpeed;
  @BindView(R.id.distance) TextView distance;
  @BindView(R.id.accuracy) TextView accuracy;
  @BindView(R.id.time) Chronometer time;
  private ProgressBarCircularIndeterminate progressBarCircularIndeterminate;
  private Data.onGpsServiceUpdate onGpsServiceUpdate;
  private LocationManager mLocationManager;
  private static Data data;
  private boolean ishold = false;
  private double lat, lng;
  // ...중략

    @Override
    protected void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);
      setContentView(R.layout.activity_main);
      ButterKnife.bind(this);

      // 화면 꺼짐 방지.
      getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
      //데이터 객체.
      data = new Data(onGpsServiceUpdate);

      // GPS 수신기를 통해 위치를 받아올 LocationManager 객체.
      mLocationManager = (LocationManager) this.getSystemService(Context.LOCATION_SERVICE);

      //onGpsServiceUpdate를 구현한다.
      onGpsServiceUpdate = new Data.onGpsServiceUpdate() {
            @Override
            public void update() {
                maxSpeed.setText(data.getMaxSpeed());
                distance.setText(data.getDistance());
                calorie.setText(data.getCalorieMeter());
                if (sharedPreferences.getBoolean("auto_average", false)) {
                    averageSpeed.setText(data.getAverageSpeedMotion());
                } else {
                    averageSpeed.setText(data.getAverageSpeed());
                }
            }
      };

      time.setText("00:00:00");
      // 주행 시간 표시를 위해 Chronometer 리스너 인터페이스 구현
      time.setOnChronometerTickListener(new Chronometer.OnChronometerTickListener() {
            boolean isPair = true;

            @Override
            public void onChronometerTick(Chronometer chrono) {
                long time;
                if (data.isRunning()) {
                    time = SystemClock.elapsedRealtime() - chrono.getBase();
                    data.setTime(time);
                } else {
                    time = data.getTime();
                }
                // 밀리초 단위 이므로 1000을 곱해서 나눠준다.
                int h = (int) (time / 3600000);
                int m = (int) (time - h * 3600000) / 60000;
                int s = (int) (time - h * 3600000 - m * 60000) / 1000;
                String hh = h < 10 ? "0" + h : h + "";
                String mm = m < 10 ? "0" + m : m + "";
                String ss = s < 10 ? "0" + s : s + "";
                chrono.setText(hh + ":" + mm + ":" + ss);

                if (data.isRunning()) {
                    chrono.setText(hh + ":" + mm + ":" + ss);
                } else {
                    if (isPair) {
                        isPair = false;
                        chrono.setText(hh + ":" + mm + ":" + ss);
                    } else {
                        isPair = true;
                        chrono.setText("");
                    }
                }

            }
        });
        //...중략
  }
  //주행 시작, 주행 일시 정지.
  @OnClick(R.id.fab) void onFabClick(View v) {
    if(!data.isRunning) {
      Intent intent = new Intent(getApplicationContext(), GPSService.class);
      startService(intent);
      //...중략
    } else {
      Intent stop = new Intent(getApplicationContext(), GPSService.class);
      stopService(stop);
      //...중략
    }
  }
  // 주행 종료.
  @OnClick(R.id.fab) void onRefreshClick(View v) {

    resetData(); // 사용자 UI 초기화.
    Intent stop = new Intent(getApplicationContext(), GPSService.class);
    stopService(stop);
    //...중략
  }

  // 사용자 UI 초기화를 위한 메서드
  private void resetData() {
       fab.setImageDrawable(ContextCompat.getDrawable(this,R.drawable.ic_action_play));
       refresh.setVisibility(View.INVISIBLE);
       time.stop();
       maxSpeed.setText("");
       averageSpeed.setText("");
       distance.setText("");
       calorie.setText("");
       time.setText("00:00:00");

       data = new Data(onGpsServiceUpdate);
   }

  // Service 클래스에 값을 전달하기 위한 Getter.
  public static Data getData() {
     return data;
  }

  @Override
  public void onLocationChanged(final Location location) {
      lat = location.getLatitude();
      lng = location.getLongitude();

      if (location.hasAccuracy()) {
          // 위치 정확도 표시.
          SpannableString s = new SpannableString(String.format(Locale.KOREA, "%.0f", location.getAccuracy()) + "m");
          s.setSpan(new RelativeSizeSpan(0.75f), s.length() - 1, s.length(), 0);
          accuracy.setText(s);
      }
      if (location.hasSpeed()) {
            // progressBarCircularIndeterminate 안 보이게 지우기.
            progressBarCircularIndeterminate.setVisibility(View.GONE);
            String speed = String.format(Locale.KOREA, "%.0f", location.getSpeed() * 3.6) + "km/h";

            if (sharedPreferences.getBoolean("miles_per_hour", false)) { // 마일로 변경
                speed = String.format(Locale.KOREA, "%.0f", location.getSpeed() * 3.6 * 0.62137119) + "mi/h";
            }
            SpannableString s = new SpannableString(speed);
            s.setSpan(new RelativeSizeSpan(0.25f), s.length() - 4, s.length(), 0);
            currentSpeed.setText(s);
      }
      //...중략
  }
  @Override
  public void onStatusChanged(String s, int i, Bundle bundle) {}

  @Override
  public void onProviderEnabled(String s) {}

  @Override
  public void onProviderDisabled(String s) {}

  // 주행 속도에 따라 사용자의 스마트폰 터치를 불가능하게 하기 위한 메서드  
  @Override
  public boolean dispatchKeyEvent(KeyEvent event) {
       if (event.getAction() == KeyEvent.ACTION_DOWN) {
           if(event.getKeyCode() == KeyEvent.KEYCODE_VOLUME_UP&&!ishold) {
               //화면 터치 불가.
               getWindow().addFlags(WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE);
               holder.setImageDrawable(ContextCompat.getDrawable(this, R.drawable.ic_lock_black_24dp));
               ishold = true;
               Toast.makeText(getApplicationContext(),getString(R.string.run_lock),Toast.LENGTH_SHORT).show();
               return true;
           } else if(event.getKeyCode() == KeyEvent.KEYCODE_VOLUME_UP&&ishold) {
               //화면 터치 불가 취소.
               getWindow().clearFlags(WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE);
               holder.setImageDrawable(ContextCompat.getDrawable(this, R.drawable.ic_lock_open_black_24dp));
               ishold = false;
               Toast.makeText(getApplicationContext(),getString(R.string.un_lock),Toast.LENGTH_SHORT).show();
               return true;
           }
       }
         return super.dispatchKeyEvent(event);
     }  
  // 사용자와 상호 작용이 가능할 때 실행되야 할 작업.   
  @Override
  protected void onResume() {

    if (!data.isRunning()) {
      // Gson 라이브러리를 이용해 데이터를 json 포멧 String을 가져 온다.
      Gson gson = new Gson();
      String json = sharedPreferences.getString("data", "");
      data = gson.fromJson(json, Data.class);
    }
    // data 존재 여부를 통해 값 재할당.
    if (data == null) {
        data = new Data(onGpsServiceUpdate);
    } else {
        data.setOnGpsServiceUpdate(onGpsServiceUpdate);
    }
    //...중략
  }
  // 앱의 화면 전환 시 값 저장.
  @Override
  protected void onPause() {
      // SharedPreferences 클래스를 통해 json 데이터 임시 저장.
      SharedPreferences.Editor prefsEditor = sharedPreferences.edit();
      //Gson 라이브러리를 이용해 데이터를 json 포멧 String 저장한다.
      Gson gson = new Gson();
      String json = gson.toJson(data);
      prefsEditor.putString("data", json);
      prefsEditor.apply();
    //...중략
  }
  // 각종 자원을 해제.
  @Override
  public void onDestroy() {
    Intent stop = new Intent(getApplicationContext(), GPSService.class);
    stopService(stop);
    resetData();
    mLocationManager.removeUpdates(this);
    //...중략
  }
  //...중략
}

여기선 JSON 포멧을 통해 여러 출력 데이터를 포멧팅 하여 임시 저장하고 있다. 어디까지나 안전모의 예이며, Application 클래스를 이용하거나 다른 방법을 통해 화면 전환에 따른 데이터 임시 저장 설계가 가능하다. 아울러, 실제 실행 화면은 UI 관련 지난 포스팅에서 확인 할 수 있다.

안전모 음성 안내

앞서 짧게 언급했지만, 안전모에서 사용된 음성 안내 기능은 안드로이드에 기본 내장된 TTS 엔진을 이용해 코딩되었다. TTS와 관련된 다른 많은 3rd party 라이브러리 또한 존재한다. 안드로이드 설정 창에 “글자 읽어주기” 부분이 이 TTS 설정 부분이라고 할 수 있다. 코드로 TTS 엔진을 초기화하고 음성의 속도나 피치 등을 컨트롤할 수 있지만, 사용자가 설정 값을 변경하면 시스템 적으로 변경된 값에 따라 일부 설정이 변경되게 된다. 나의 코드는 사용자를 이길 수 없다? 또한, 다국어 지원을 위해선, 설정 언어 별 음성 안내 분기 처리를 진행해줘야 한다.

TTS 엔진을 사용하는 방법은 크게 2가지로 구분된다. 먼저, andorid 패키지 밑에 있는 TextToSpeech 클래스의 OnInitListener를 바로 implements 해서 메서드를 오버라이딩하여 사용하는 방법과, OnInitListener 임시 구현 객체를 구현하는 방법이 있다. 아래 코드는 MainActivity에서 임시 구현 객체를 구현한 코드이다.

import android.speech.tts.TextToSpeech;
import android.annotation.TargetApi;

//...중략
public class MainActivity implements LocationListener {
  private TextToSpeech tts; // tts 객체 선언.
  private int MY_DATA_CHECK_CODE = 0; // TTS Check 코드.
  //...중략
  @Override
  protected void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);
    setContentView(R.layout.activity_main);

    //TTS 사용 가능 여부 체크.
    try {
        /*
        앱을 처음 실행할 시에는 TTS 엔진을 체크하는 setAction을 통해 사용자에게 음성 안내를 위해 사용할 TTS 엔진을 선택하는 화면을 자동으로 띄울수 있다.
        엔진 종류는 기본적으로 안드로이드이므로 Google TTS 엔진이 있다. 삼성의 경우 자체 삼성 TTS 엔진이 있고, LG 또한 자체 TTS 엔진이 있다.
        주의. (TTS 엔진을 정상적으로 사용할 수 없는 OS 버전도 있다..!)
        */
        Intent checkTTS = new Intent();
        checkTTS.setAction(TextToSpeech.Engine.ACTION_CHECK_TTS_DATA);
        startActivityForResult(checkTTS, MY_DATA_CHECK_CODE);
      }catch (ActivityNotFoundException e){
        //TTS 에러 발생시 실행.
        Toast.makeText(getApplicationContext(), getResources().getString(R.string.tts_not_setup), Toast.LENGTH_SHORT).show();
      }
      //...중략
  }

  // TTS 엔진 세팅
  protected void onActivityResult(int requestCode, int resultCode, Intent data){
        // TTS check code
        if(requestCode == MY_DATA_CHECK_CODE){
            // 정상적으로 TTS 엔진 선택 세팅이 이루어졌을 경우.
            if (resultCode == TextToSpeech.Engine.CHECK_VOICE_DATA_PASS) {
              // TextToSpeech.OnInitListener 를 구현한다.
                tts = new TextToSpeech(this, new TextToSpeech.OnInitListener() {
                    // onInit 메서드를 오버라이딩해서 필요한 설정 작업을 진행한다.
                    @Override
                    public void onInit(int status) {
                        if (status == TextToSpeech.SUCCESS) { // 정상적으로 설정이 되었다면,
                            // 현재 시스템의 언어 설정이 TTS 엔진이 사용 가능한 언어라면,
                            if (tts.isLanguageAvailable(Locale.getDefault()) == TextToSpeech.LANG_AVAILABLE)
                                // TTS 음성 언어 설정을 시스템에 설정된 기본 언어로 설정한다.
                                tts.setLanguage(Locale.getDefault());
                        } else if (status == TextToSpeech.ERROR) {
                            // 에러 발생시 에러 토스트를 띄운다.
                            Toast.makeText(getApplicationContext(), getString(R.string.tts_not_setup), Toast.LENGTH_LONG).show();
                        }
                    }
                });
            } else {
                // 처음 TTS 엔진을 설정한 경우에 설치가 필요하다면, TTS 엔진을 다운로드한다.
                Intent installTTS = new Intent();
                installTTS.setAction(TextToSpeech.Engine.ACTION_INSTALL_TTS_DATA);
                startActivity(installTTS);
            }
        }
    }
      // 실제 호출되어 음성 안내를 실행할 메서드.
      private void speak(String s){
          if(Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP){
              ttsGreater21(s);
          } else {
              ttsUnder20(s);
          }
      }

      //아래 speak 메서드는 인자로 3개만 받고 있는데, 해당 메서드는 API 20(킷켓 4.4) 이하에서만 동작되며, deprecated 되었다..!  
      @SuppressWarnings("deprecation")
      private void ttsUnder20(String text) {
          HashMap<String, String> map = new HashMap<>();
          map.put(TextToSpeech.Engine.KEY_PARAM_UTTERANCE_ID, "MessageId");
          tts.speak(text, TextToSpeech.QUEUE_FLUSH, map);
      }
      //아래 speak 메서드는 인자를 4개 받고, API 21(롤리팝 5.0) 이상에서 동작 가능하도록 되어있다. 이놈의 deprecated..
      @TargetApi(Build.VERSION_CODES.LOLLIPOP)
      private void ttsGreater21(String text) {
          String utteranceId = this.hashCode() + "";
          tts.speak(text, TextToSpeech.QUEUE_FLUSH, null, utteranceId);
      }
      // TTS 자원 해제.
      @Override
      protected void onStop() {
        super.onStop();
        if (tts!= null) {
              tts.stop();
        }
      }
      // TTS 자원 해제.
      @Override
      public void onDestroy() {
        super.onDestroy();
        if (tts != null) {
             tts.stop();
             tts.shutdown();
        }
      }
  //...중략
}

위 두 개의 코드 예는 사실 모두 MainActivity 하나의 클래스에 작성된 코드이다. 구분을 위해 분리되어 작성했으며, 위치 측정을 통해 얻어온 데이터를 통해 상황에 따른 음성 안내 서비스가 이루어지게 되는 구조이다.

이렇게 간단하게? 안전모의 위치 측정 부분과 음성 안내 부분에 대해서 정리해보았다. 오랜만에 안드로이드 정리라 그런지 조금 낯설다..? 아무튼, 위치 측정 부분과 음성 안내 부분은 중복되는 코드를 제거하고 보기 좋게 리팩토링하는 작업이 많이 필요한 부분이다. 그건.. 차차 진행해 보는 걸로 하고, 다음은 안전모 넘어짐 감지 부분에 대해 알아보는 걸로~