Android Data Binding의 DirtyFlag 알아보기

지난 Devfest Seoul 2018과 Devfest Busan 2018에서 ‘Android DataBinding for Modularization, ViewModel and Testing’이라는 주제로 발표를 했다.

데이터 바인딩의 꽃이라고 할 수 있는 양방향 데이터 바인딩, 이를 위한 DirtyFlag가 코드에서 어떻게 동작하는지 살펴보았다. 추가적으로 데이터 바인딩에 있는 Annotation의 사용법과 UI 모듈화, 그리고 Testing에 관해서 공유했다.

그냥.. 궁금했던 개발자 – Generated Binding Class

데이터 바인딩 라이브러리는 레이아웃(xml)의 변수(Variable)나 뷰(View)에 접근할 수 있는 바인딩 클래스이며 컴파일 타임에 생성된다. 예를 들어, layout 태그를 가지는 activity_main.xml 파일이 있다면 컴파일시 ActivityMainBinding 다음과 같은 Java 파일이 생성된다.

activity_main.xml

<layout …>
    <data>
        <import type="com.github.kimkevin.devfestseoul18.ValidationUtil"/>
        <variable
            name="vm"
            type="com.github.kimkevin.devfestseoul18.MainViewModel"/>
    </data>

    <android.support.constraint.ConstraintLayout …>
        <TextView
            android:text="@{vm.title}"…/>

        <EditText
            android:text="@={vm.authCode}"…/>

        <Button
            android:enabled="@{ValidationUtil.isCodeValid(vm.authCode)}"
            android:onClick="@{() -> vm.login()}"…/>
    </android.support.constraint.ConstraintLayout>
</layout>

위와 같은 xml이 Java 파일로 변경되었다면 레이아웃을 동작 시키기 위해 필요한 변수는 무엇일까?

  • 선언된 View에 접근하기 위한 ConstraintLayout, TextView, EditText, Button 4개의 변수
  • variable로 전달된 vm(ViewModel) 변수

그리고 추가적으로 특정 View와 이벤트를 전달하고 받을 수 있는 리스너가 필요하다.

  • View의 이벤트를 처리할 수 있는 리스너, Button의 onClickListener
  • 양방향 데이터 바인딩을 위한 역방향 바인딩 리스너, EditText에서 android:text에 사용되는 InverseBindingListener

실제로 생성된 ActivityMainBinding.class를 확인해보자.

public class ActivityMainTestBinding extends android.databinding.ViewDataBinding implements android.databinding.generated.callback.OnClickListener.Listener {
    …
    // views
    @NonNull public final android.widget.Button button;
    @NonNull public final android.widget.EditText editText;
    @NonNull private final android.support.constraint.ConstraintLayout mboundView0;
    @NonNull public final android.widget.TextView textView;
    // variables
    @Nullable private com.github.kimkevin.devfestseoul18.MainViewModel mVm;
    @Nullable private final android.view.View.OnClickListener mCallback1;
    // Inverse Binding Event Handlers
    private android.databinding.InverseBindingListener editTextandroidTextAttrChanged = new android.databinding.InverseBindingListener() {
        @Override
        public void onChange() {
        …

TextView는 setText(vm.title) 호출로 데이터가 바인딩 되었을 것이고, EditText는 TextWatcher 등록으로 입력된 텍스트가 vm.authCode역바인딩 되었을 것이다. 그리고 Button에는 vm.login()호출되는 OnClickListener를 등록했을 것이다. 여기까지는 안드로이드 UI 개발을 해본 경험이 있다면 예측이 가능하다.

<Button android:enabled="@{ValidationUtil.isCodeValid(vm.code)}” />

만약 위의 코드가 동작하도록 실제 코드로 작성한다면 어떻게 만들 수 있을까? 실제로 개발하는 과정에서 있었던 나의 궁금증이었다. EditText의 텍스트가 변경될 때마다 button.setEnabled(ValidationUtil.isCodeValid(vm.authCode))을 리스너에서 호출해주면 될 것이라는 추측을 할 수 있다. 해답을 찾기 위해서 데이터 바인딩 라이브러리가 생성한 바인딩 클래스를 살펴보고 이를 주제로 발표하는 계기가 되었다.

/* flag mapping … */ 주석의 발견 – DirtyFlag

flag mapping

ActivityMainBinding.java의 하단에는 flag mppaing으로 시작하는 주석으로 16진수 flag와 데이터 바인딩에 사용되는 필드 정보(ex: vm)가 있다. 그럼 16진수의 flag는 어디에서 어떻게 사용되는 걸까? 단서를 찾아보자.

가장 먼저 flag 0은 0x1L(16진수)이며 vm.authCode와 관련 있는 코드를 찾아보았다.

private boolean onChangeVmAuthCode(android.arch.lifecycle.MutableLiveData<java.lang.String> VmAuthCode, int fieldId) {
    if (fieldId == BR._all) {
        synchronized(this) {
            mDirtyFlags |= 0x1L;
        }
        return true;
    }
    return false;
}

ViewModel의 authCode가 변경되면 mDirtyFlags|= 대입 연산자를 통해 vm.authCode(0x1L)와 OR 비트연산을 한다. 다음 0x2L 값을 가지는 flag 1의 vm과 관련 있는 코드를 찾아보았다.

public void setVm(@Nullable com.github.kimkevin.devfestseoul18.MainViewModel Vm) {
    this.mVm = Vm;
    synchronized(this) {
        mDirtyFlags |= 0x2L;
    }
    notifyPropertyChanged(BR.vm);
    super.requestRebind();
}

ViewModel이 바인딩 클래스에 setVm이 되면 위에서 처럼 mDirtyFlags는 |= 대입 연산자를 통해 vm(0x2L)과 OR 비트연산을 한다.

그러면 mDirtyFlags의 초기값을 무엇일까?

@Override
public void invalidateAll() {
    synchronized(this) {
            mDirtyFlags = 0x4L;
    }
    requestRebind();
}

ActivityMainBinding 바인딩 클래스의 생성자를 보면 가장 마지막에 invalidateAll() 함수가 호출된다. 되는데 이때 mDirtyFlags의 초기값이 설정된다.

DirtyFlag mapping

초기값이 0x4L인 이유는 플래그 비트가 flag << 1 하나씩 왼쪽으로 이동하기 때문이다. vm.authCode가 2진수 001(0x1L)이고 왼쪽으로 시프트 연산을 하면 vm은 2진수 010(0x2L)이다. 그럼 초기값 설정을 위해 플래그 비트를 하나 둔다면, 한번더 왼쪽으로 시프트 연산을 한 진수 100(0x4L)이 된다.

여기까지는 바인딩 클래스가 동작하기 위해서 필요한 플래그의 기본 정보에 대해서 살펴보았다.

DirtyFlags의 동작 살펴보기

지금까지 코드로 언급한 mDirtyFlags는 무엇일까? 처음 접하는 단어라 찾아보니 ZETAWIKI에서는 더티비트캐시 내용에 변경이 있었음을 기록한 플래그 비트이며 캐시 블록마다 더티 비트가 1개씩 있음. 0이면 ‘변동 없음’, 1이면 ‘변동 있음(갱신 필요[1])’의 의미 라고 말한다.

안드로이드의 데이터바인딩에서도 View(UI)에 데이터의 바인딩과 리바인딩을 위해 더티비트를 사용했다. 재밌는 비트라는 생각이 들었다.

그럼 데이터가 어떻게 바인딩 또는 리바인딩 되는지 직접 코드를 통해서 살펴보면서 앞서 가진 궁금증도 해결해보자.

View에 데이터가 바인딩되는 곳은 executeBindings함수에서 모두 호출된다.

@Override
protected void executeBindings() {
    ...
    dirtyFlags = mDirtyFlags;
    if ((dirtyFlags & 0x7L) != 0) {
        if ((dirtyFlags & 0x6L) != 0) {
             textViewAndroidStringTitleVmYear = textView...getString(R.string.title, vmYear);
        }
        // read vm.authCode.getValue()
        vmAuthCodeGetValue = vmAuthCode.getValue();
        // read ValidationUtil.isCodeValid(vm.authCode.getValue())
        vmAuthCodeLengthInt6 = ValidationUtil.isCodeValid(vmAuthCodeLength);
    }
    if ((dirtyFlags & 0x7L) != 0) {
        this.button.setEnabled(vmAuthCodeLengthInt6);
        TextViewBindingAdapter.setText(this.editText, vmAuthCodeGetValue);
    }
    if ((dirtyFlags & 0x4L) != 0) {
        this.button.setOnClickListener(mCallback1);
        TextViewBindingAdapter.setTextWatcher(this.editText, null, null, null, 
                                              editTextandroidTextAttrChanged);
    }
    if ((dirtyFlags & 0x6L) != 0) {
        TextViewBindingAdapter.setText(this.textView, textViewAndroidStringTitleVmYear);   
    }
}

코드를 보면 많은 mDirtyFlags의 조건문을 볼 수 있는데, Debug로 Breakpoint를 찍은 다음 플래그가 어떻게 변경되는지 살펴보자.

Debugging bbinding class by breakpoint - 1

바인딩 클래스를 생성하면 위에서 설명한 것처럼 mDirtyFlags0x4L로 초기화 된다.

Debugging bbinding class by breakpoint - 2

그리고 binding.setVm(viewModel)이 호출되면 mDirtyFlags는 flag vm(0x2L)와 mDirtyFlags |= 0x2L 비트 연산으로 0x06L이 된다. 바인딩 클래스가 생성되고 변수들이 셋되면 executeBindings()은 어떤 동작을 하게 될까?

Debugging bbinding class by breakpoint - 3

mDirtyFlags가 0x6L일 때, 동작하는 첫 번째 조건문의 0x6L & 0x7L 결과값은 0x6L로 0이 아니기 때문에 true가 된다. 마찬가지로 모든 조건문을 연산해보면 다음과 같은 결과를 확인할 수 있다.

Debugging bbinding class by breakpoint - 4

바인딩 클래스가 생성되고 변수들이 초기화 되면 executeBindings()에 있는 모든 조건문이 호출로 뷰에 데이터가 바인딩되고 리스너가 등록되는 것을 확인할 수 있다.

Debugging bbinding class by breakpoint - 5

그러면 글을 쓰도록 동기를 만들어 주었던 EditText의 텍스트 입력시 Button의 활성화는 어떻게 호출될까?

Debugging bbinding class by breakpoint - 6

먼저 ViewModel에 있는 데이터를 가져온 후에 this.button.setEnabled(vmAuthCodeLengthInt6)가 호출되는 것을 알 수 있다. executeBindings()에 많은 조건문이 있었던 이유는 데이터 변경시 필요한 로직 이외 다른 로직들은 변경 사항이 없기 때문에 호출되지 않도록 하기 위해서다. 이런 방식으로 데이터 바인딩 라이브러리는 데이터와 레이아웃을 바인딩하는데 필요한 글루 코드를 최소화할 수 있다.

평소에 개발관련 툴이나 라이브러리를 만드는 것에 관심이 많기 때문에 가끔씩 코드를 들여다 보면서 창작자의 철학이나 코드를 많이 배우고 있지만 이번 데이터 바인딩 라이브러리를 살펴보면서 더티 비트를 알게 되어 좋은 경험이 된 것 같다.

Android WeChat Pay 개발하기

Why WeChat Pay?

2015년 1월, 회사 서비스가 중국에 오픈하면서 베이징으로 출장을 다녀온 적이 있다. 처음으로 추가 했던 결제 플랫폼은 중국에서 높은 점유율을 차지하던 알리페이(Alipay)였고 1년이 지나 2016년 초에 다시 갔을 땐, 레스토랑부터 심지어 길에 있던 자판기에서 위챗페이(WeChat Pay)의 QR코드를 이용해 간편결제를 할 수 있을 만큼 성장 속도가 빨랐다. [통계로 보는 위챗] 중국 최대 메신저 ‘위챗’, ‘위챗페이’의 모든 것 다음 기사를 보면 “2016년 1분기를 기준으로 7억 6200만 명에 달합니다.”라는 내용을 볼 수 있다. 그만큼 젊은 층에서는 위챗이 알려진 만큼 위챗페이를 이용한 결제가 많아졌다.

계정 생성 및 애플리케이션 등록하기

WeChat Open Platform

개발을 시작하기 전에 중국어로 된 위챗 오픈 플랫폼에서 계정을 생성하고 앱을 등록해야 하며 이때 패키지 이름과 서명 정보를 등록해야 한다.

WeChat Payment Wiki Page

위챗페이 결제 플랫폼 위키에서 APP支付메뉴의 5번째 하위 메뉴 APP端开发步骤(APP 단말 개발)에 Android, iOS 플랫폼 별로 개발과정이 설명되어 있다. 하지만 페이지에서 다른 언어를 지원하지 않기 때문에 구글 번역기로 페이지 번역했지만 부족한 부분이 있어서 플리토(Flitto)로 번역한 한글 번역 – Android 개발 요점 설명 문서를 참고하자.

패키지 이름은 개발 중인 앱의 패키지 이름을 사용하고 서명의 경우, 위의 번역 문서대로 앱을 다운 받은 다음에 서명을 만들어서 입력하면 된다. 그리고 경험으로는 앱 정보 등록 시 서명을 만든 테스트 기기에서는 서명되지 않는 앱일지라도 위쳇 로그인이 가능하다.

SDK 설치하기

Installation Android SDK

위챗 로그인 개발로 추가된 SDK에 결제 API가 빠져서 이번에 업데이트(v3.1.1)를 진행했다. Android资源下载 페이지의 최상단에 있는 “使用微信分享、登录、收藏、支付等功能需要的库以及文件。点击下载 Android开发工具包(위쳇 모멘트, 로그인, 즐겨찾기 저장, 결제 등 기능이 필요한 문서는 Android를 통하여 다운받으시기 바랍니다)” 클릭해서 다운로드한 후 lib폴더 안에서 jar를 추가하면 된다. 만약 현재 글로벌(구글플레이)과 중국으로 서비스를 개발하고 있다면 productFlavors에서 중국 flavor를 추가해서 해당 디렉터리 안에 라이브러리 폴더를 만들어서 별도로 관리하는 편이 좋다.

안드로이드 클라이언트 개발

wechatpay_appid

개발을 시작하기 전에 위쳇 오픈 플랫폼에 앱을 등록한 후, 생성된 AppID가 필요하다. 또한 중국에 서비스를 하면서 페이팔, 알리페이, 구글 인앱결제를 개발 했었지만 위쳇페이에서는 다른 결제 플랫폼과는 다르게 통합주문(개인적으로는 선주문으로 이해함)이라는 과정이 있다. 서버 개발자라면 위쳇 페이 API 문서를 보면서 반드시 이해를 해야 하고, 클라이언트 개발자라도 결제가 정상적으로 되지 않는다면 파라미터나 해당 키값을 확인하기 위해서 알아 두는 것을 추천한다.

위쳇페이 결제 과정

wechatpay_flow

  • 1 클라이언트에서 상품의 아이디(ex: product_id)로 서비스의 서버에 주문을 넣는다.
  • 2 서버에서는 웨이신 통합 주문 API을 이용해 주문을 생성한다.
  • 3 prepaid_id가 포함된 데이터(nonce_str, mch_id, timestamp, sign)와 서비스의 상품 주문 아이디를 클라이언트에 응답으로 내려준다.
  • 4 클라이언트에서는 데이터를 가지고 위쳇 애플리케이션으로 API 요청을 보낸다.

위쳇페이 창구 열기

IWXAPI api = WXAPIFactory.createWXAPI((Context) purchasePointView, Constants.WEIXIN_APP_ID);
api.registerApp(Constants.WEIXIN_APP_ID);

if (api.isWXAppInstalled()) {
    PayReq request = new PayReq();
    request.appId = unifiedOrderItem.getAppId();
    request.nonceStr = unifiedOrderItem.getNonceStr();
    request.packageValue = "Sign=WXPay";
    request.partnerId = unifiedOrderItem.getPartnerId();
    request.prepayId = unifiedOrderItem.getPrepayId();
    request.timeStamp = String.valueOf(unifiedOrderItem.getTimeStamp());
    request.sign = unifiedOrderItem.getSign();
    api.sendReq(request);
} 
  • 5 위펫페이 결제 창구를 오픈하면서 WXPayEntryActivity가 실행된다.

WXPayEntryActivity

public class WXPayEntryActivity extends Activity implements IWXAPIEventHandler {
  public static final String TAG = "WXPayEntryActivity";
  public static final int PAYMENT_SUCCESS = 0;
  public static final int PAYMENT_ERROR = -1;
  public static final int PAYMENT_USER_CANCELED = -2;

  private IWXAPI api;

  @Override
  protected void onCreate(@Nullable Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);
    setContentView(new View(this));

    api = WXAPIFactory.createWXAPI(this, Constants.WEIXIN_APP_ID);
    api.handleIntent(getIntent(), this);
  }

  @Override
  protected void onNewIntent(Intent intent) {
    super.onNewIntent(intent);
    setIntent(intent);
    api.handleIntent(intent, this);
  }

  @Override
  public void onReq(BaseReq baseReq) { }

  @Override
  public void onResp(BaseResp baseResp) {
    if (baseResp.getType() == ConstantsAPI.COMMAND_PAY_BY_WX) {
      Intent intent = new Intent(TAG);
      intent.putExtra("errCode", baseResp.errCode);
      sendBroadcast(intent);
    }
    finish();
  }
}
  • 6 결제를 하면 창구가 닫히면서 보낸 결제 정보를 WXPayEntryActivityonResp에서 확인한다.

WXAPIFactory

public final boolean openWXApp() {
    …
    } else {
      try {
this.context.startActivity(this.context.getPackageManager().getLaunchIntentForPackage("com.tencent.mm"));
    …
  }

WXAPIFactory에서 위쳇앱을 실행할 때, startActivity로 실행을 하기 때문에 흔히 액티비티간의 통신 방법으로 사용하는 setResult가 정상적으로 실행되지 않는다. 그래서 onResp에서 에러코드를 리턴해주기 위해 브로드캐스트(broadcase)를 사용했다.

  • 7 결제가 성공이면 서버로 부터 받은 주문 ID를 검증 요청한다.
  • 8 서버는 주문 ID를 이용해 웨이신 주문 확인 API로 주문을 최종 확인한다.
  • 9 클라이언트에게 결제 확인 응답을 보낸다.**

위쳇페이 결제 완료시 BaseResp.errorCode가 항상 -1로 리턴되는 이슈

위의 과정대로 라이브러리를 추가하고 파일을 추가해주면 간단하게 위쳇 앱에서 결제를 완료하고 개발중인 앱으로 결과까지 받아볼 수 있다. 하지만 그 결과가 -1이 되면 정상적으로 결제가 완료된 것을 뜻하지 않는다.

erroCode가 -1인 경우, WeChat Pay API 개발 문서를 보면 “발생가능한이유 : 사인(sign)오류, APPID 미등록, 프로젝트상에서 APPID 설정오류, 등록된 APPID와 세팅한것 맞지 않는 경우, 그 외” 이유로 인해 발생할 수 있다. app_id의 경우 보다는 sign 값의 오류일 가능성이 높다.

클라이언트에서 sign 생성하기

// request.sign = unifiedOrderItem.getSign();

SortedMap<String, String> data = new TreeMap<>();
data.put("appid", request.appId);
data.put("noncestr", request.nonceStr);
data.put("package", request.packageValue);
data.put("partnerid", request.partnerId);
data.put("prepayid", request.prepayId);
data.put("timestamp", request.timeStamp);
request.sign = createSign(data);

private String createSign(SortedMap<String, String> parameters) {
    StringBuffer sb = new StringBuffer();
    Set es = parameters.entrySet();
    Iterator it = es.iterator();
    while (it.hasNext()) {
      Map.Entry entry = (Map.Entry) it.next();
      String k = (String) entry.getKey();
      String v = (String) entry.getValue();
      if (null != v && !"".equals(v) && !"sign".equals(k)
          && !"key".equals(k)) {
        sb.append(k + "=" + v + "&");
      }
    }
    sb.append("key=" + "00123456789vmfflxhvmfflxhvmfflxh");
    LogUtil.d("Create Sign : " + sb.toString());
    String sign = MD5Util.MD5Encode(sb.toString(), "utf-8")
        .toUpperCase();
    return sign;
  }
}

서버에서 받은 sign값을 클라이언트에서 생성해서 위쳇으로 API 요청을 할 때 보내서 테스트해보자.

// data.put("partnerid", request.partnerId);
data.put("mch_id", request.partnerId);

이때 주의할 점은 WeChat Pay 결제 확인 API 문서에 있는 대로 키 값을 mch_id으로 사용해서는 안 된다.

// data.put("prepayid", request.prepayId);
data.put("prepay_id", request.prepayId);

마찬가지로 prepay_id를 키로 사용해 만든 sign 값으로 결제를 시도하면 결제가 실패한다. 위에 있는 키 값으로 정확한 데이터를 넣어주면 문제없는 sign 값을 생성할 수 있다. 그리고 중국을 서비스하기 위해서 외부 서비스를 연동할 때, 구글 검색은 크게 도움이 되지 않는다. 이때 클래스나 코드를 기반으로 바이두 검색을 추천한다. 중국어로 이해하긴 어렵겠지만, 자신의 코드를 비교해보면 겪고 있는 문제를 조금 더 쉽게 해결할 수 있다.

Read byte array from binary data of retrofit response

현재 서버에서 이미지를 binary data로 내려주면 클라이언트에서는 데이터를 읽은 후 byte array로 변환하고 있다. 이전에는 DEPRECATEDvolley 라이브러리를 이용해 Request를 커스텀하게 수정해서 사용했다.

public class ByteArrayRequest extends Request<byte[]> {
  private Response.Listener<byte[]> listener;
  …
  @Override
  protected Response<byte[]> parseNetworkResponse(NetworkResponse response) {
     return Response.success(response.data, HttpHeaderParser.parseCacheHeaders(response));
}

그리고 요즘에는 volley에서 Retrofit + RxJava 라이브러리로 변경하는 작업을 진행하는 중이다.

@GET(“books”)
Observable<Result<ResponseBody>> getImage(@Query(“id”) String id);

Retrofit으로 byte[]로 데이터 가져오기

Retrofit은 프로덕션에 적용한 지 얼마 안되었지만 Result에서 result.response()를 통해 얻은 객체 Response에는 크게 body()errorBody() 메소드가 있다. 여기에서 body는 ResponseBody가 되고 errorBody의 경우, 예를 들어 서버에서 404 코드와 에러 메시지가 포함된 에러 JSON 객체를 내려주면 String으로 받을 수 있다.

ResponseBody

public final InputStream byteStream() {
  return source().inputStream();
}

byte[] bytes() { 
  … 
  BufferedSource source = source();
  byte[] bytes;
  try {
    bytes = source.readByteArray();
  ...
}

String string() { 
  …
  BufferedSource source = source();
  try {
    Charset charset = Util.bomAwareCharset(source, charset());
    return source.readString(charset); 
  …
}

ReponseBody에서 InputStream을 가져와서 버퍼로 읽는 방법이나 byte[] 또는 String으로 서버로 받은 body를 가져올 수 있는데 String으로 binary 데이터를 읽게 되면 어떻게 될까?

String: ����JFIF��C��C��� ��    
�����+�}Yϭ�F39M>���������>���;��ˋ��uXʽ�w�ڤx\-[2g��k�S���H���m
[�V?[_W����#��v��}6�[��F�F�%����n�...

정상적인 이미지를 만들 수 없으므로 byte[]로 스트림에 있는 데이터를 가져와야 한다. 참고로 body.bytes()는 구현 코드를 살펴보면 body.source().readByteArray() 와 같은 동작이다.

body.source().readByteArray() : [-1, -40, -1, -32, 0, 16, 74, 70, 73, 70, 0, 1, 1, 0, 0, 1, 0, 1, 0, 0, -1, -37, 0, 67, 0, 16, 11, 12, 14, 12, 10, 16, 14, 13, 14, 18, 17, 16, 19, 24, 40, 26, 24, 2 … ]

이미지 Bitmap으로 변환하기

BitmapFactory의 decodeStream

InputStream input = body.byteStream();
Bitmap bitmap = BitmapFactory.decodeStream(input);

BitmapFactory의 decodeByteArray

Bitmap bitmap = BitmapFactory.decodeByteArray(body.bytes(), 0, body.bytes().length);

InputStream이나 byte array로 사용해서 쉽게 비트맵으로 이미지를 가져올 수 있다. 그리고 ResponseBody에 있는 BufferedSource에서 스트림으로 데이터를 읽기 때문에 주의해야 한다.

An elegant part of the java.io design is how streams can be layered for transformations like encryption and compression. Okio includes its own stream types called Source and Sink that work like InputStream and OutputStream – okio wiki 중에서

사실 위의 2번째 코드에서는 정상적으로 이미지를 그릴 수 없다.

byte[] bytes = body.bytes();
bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.length);

그 이유는 body.byte()를 호출하게 되면 스트림에 있는 데이터를 모두 읽기 때문에 다음 두 번째 호출 body.byte().length는 0이 되어 정상적인 Bitmap이 생성되지 않는다.

How to check the latest version of android support library in your SDK?

In order to the Android Support Library, we need to add the support library to dependencies in build.gradle. For example, add the following lines.

dependencies {
compile ‘com.android.support:design:25.1.0'
compile ‘com.android.support:appcompat-v7:25.1.0’
}

When do you update the support library version?

In my case, when IDE recommends to update SDK at the right top corner or when I open the SDK manager which provides the SDK tools, platforms, and others, I download and update the selected packages.

After downloaded, targetSdkVersion of defaultConfig shows the warning message with yellow underline “Not targeting the latest versions of android compatibility modes apply. consider testing this version.” for being compatible with the latest Android changes, so let’s set targetSdkVersion to 25 (Android 7.1) and run gradle build.

Finally, we will see the notice which means the support library version must be replaced to the latest version you have in your SDK and the targetSdkVersion must be the same.

How to check the latest version of android support library in your SDK?

Andorid Support Repository is local maven repository which contains all the support libraries as AAR archives from Android.

m2repository installed from dl.google.com.

As the result, you will find the latest version of support library in your local SDK. Update 25.0.1 version for com.android.support:appcompat-v7, sync project with Gradle files and run project.

[한글번역] WeChat Pay – APP端开发步骤说明

WeChat Pay App Dev Menual

원문 : APP端开发步骤说明

참고 : 아래의 번역문은 제가 직접 번역을 한 것은 아닙니다. Flitto에서 직접 한국어, 영어 번역을 참여해서 획득한 포인트로 위챗페이 개발에 필요한 문서를 번역하기 위해 번역요청(중국어->한국어)을 보내서 받은 번역문입니다. 혹시라도 위챗개발 문서가 필요한 개발자분들에게 도움이 되고자 공유합니다. 다수의 번역가가 참여했기 때문에 전체 문장이 다소 어색할 수 있음을 알려드립니다. 그리고 안드로이드 메뉴얼만 번역하였고, 4000원 비용이 들었습니다.

Android 개발 요점 설명

1. 백그라운드 설정

위챗 오픈플랫폼에서 개발 어플리케이션을 신청하시면 위챗 오픈플랫폼에서는 어플리케이션의 고유 식별 아이디를 생성해 드립니다. 안전한 결제를 위해 오픈플랫폼에 업체의 어플리케이션 패키지 이름과 어플리케이션 서명(signature)을 연동시키셔야 합니다. 연동 후에 정상적으로 결제가 가능합니다. 설정화면은 <오픈플랫폼>의 <관리센터/어플리케이션 수정/개발데이터 수정>메뉴에 있으며 그림 8.8의 빨간 네모로 표시되어 있는 부분입니다.

8.8

패키지명: 어플리케이션 프로젝트의 AndroidManifest.xml에 설정한 package 이름값이다. 예를들어 DEMO에서 package값은 net.sourceforge.simcpux.

어플리케이션 서명: 프로젝트의 패키지명 및 컴파일에 사용된 keystore을 signing툴을 사용하여 생성한 32비트길이의 MD5문자열입니다. 테스트기기에 signing툴을 설치하고 실행하면 application의 서명을 생성합니다. 그림8.9와 같이 녹색문자열이 곧 어플리케이션의 서명이다. signing툴 다운로드 url https://open.weixin.qq.com/zh_CN/htmledition/res/dev/download/sdk/Gen_Signature_Android.apk

8.9

2. APP ID 등록하기

WeChat jar패킷을 개발자의 앱프로젝트로 가져옵니다. 하지만, API를 사용하려면 우선 위챗으로 APPID를 등록해야 합니다. 코드는 아래와 같습니다:

final IWXAPI msgApi = WXAPIFactory.createWXAPI(context, null);
// app id를 위챗에 등록하기
msgApi.registerApp("wxd930ea5d5a258f4f");

3. 결제 호출

업체서버로부터 결제 청구를 생성합니다. 먼저 통합구매 API를 호출해(자세한 내용은 7장 참조) 구매리스트를 생성 후, prepay_id 값을 받아서 다시 사인 후 앱에 전송하여 결제 청구를 제출한다. 아래는 웨이신 결제를 호출하는 메인 코드입니다.

IWXAPI api;
PayReq request = new PayReq();
request.appId = "wxd930ea5d5a258f4f";
request.partnerId = "1900000109";
request.prepayId= "1101000000140415649af9fc314aa427",;
request.packageValue = "Sign=WXPay";
request.nonceStr= "1101000000140429eb40476f8896f4c9";
request.timeStamp= "1398746574";
request.sign= "7FFECB600D7157C5AA49810D2D8F28BC2811827B";
api.sendReq(req);

주의: sign 필드명으로 생성되는 리스트는 API 발급 설정을 참고하세요.

4. 지불결과 콜백

위쳇 SDK Sample을 참조하여, net.sourceforge.simcpux.wxapi 패캣경로에서 WXPayEntryActivity클래스를 생성합니다.(패킷명 혹은 클래스명이 일치하지 않으면 콜백이 불가능합니다), WXPayEntryActivity클래스에서 onResp 함수 구현, 지불완료 후 위쳇 엡은 판매자 어플리케이션으로 돌아가면서 onResp 함수를 콜백합니다. 개발자는 해당 함수내에서 알림메세지를 받아야하며 기존으로 돌아가기 위한 에러코드를 판단해야 합니다. 지불 성공 후 백오피스로 가서 지불결과를 체크한 후, 유저의 실제 지불결과를 보여줍니다. 클라이언트의 복귀를 유저 결제결과로 적용하지 않도록 꼭 주의해야 합니다. 서버사이드에서 받은 결제 알림메세지 혹은 API복귀결과를 체크하여 기준으로 해야 합니다, 코드예시는 다음과 같습니다:

@Override
public void onResp(BaseResp resp) {
if (resp.getType() == ConstantsAPI.COMMAND_PAY_BY_WX){
Log.d(TAG,"onPayFinish,errCode=" + resp.errCode);
AlertDialog.Builder builder = new AlertDialog.Builder(this);
builder.setTitle(R.string.app_tip);
}
}

콜백한 errCode 리스트:

명칭 기술 솔루션
0 성공 성공화면
-1 오류 발생가능한이유 : 사인(sign)오류, APPID 미등록, 프로젝트상에서 APPID설정오류, 등록된APPID와 세팅한것 맞지 않는 경우, 그 외
-2 사용자취소 발생가능한이유 : 사용자취소버튼 누르고 APP으로 복귀

ListView의 addHeaderView(Footer)를 RecyclerView 에서 구현하기

안드로이드 앱 개발시 가장 많이 사용하던 위젯은 ListView이다. 그리고 RecyclerView는 Android 5 Lollipop 버전이 업데이트 되었을 때, Support Library V7에 추가되었고 이를 사용하기 위해서는 build.gradle에 다음과 같이 추가하면 사용할 수 있다.

compile 'com.android.support:recyclerview-v7:${supportLibraryVersion}'

기존에 ListView에서 사용하던 상, 하단에 뷰 추가를 지원하던 addHeaderViewaddFooterView가 RecyclerView에서는 더이상 지원하지 않는다. 그래서 대부분의 안드로이드 개발자들은 StackOverFlow에 ‘How to add header view to recyclerview’ 다음과 같이 검색했을 것이다. 이미 RecyclerView에 HeaderView나 FooterView를 추가해주는 오픈 소스는 많이 공개되어 있다. 필요하다면 찾아서 사용하면 쉽게 뷰를 상하단에 추가할 수 있다. 결과적으로 기존 ListView에서는 addHeaderView가 어떻게 동작 했으며 RecyclerView에서는 그 기능을 동작하도록 할 수 있는지 한번 알아보려고 한다.

HeaderViewListAdapter (android.widget)
public void addHeaderView(View v, Object data, boolean isSelectable) {
    ...
    // Wrap the adapter if it wasn't already wrapped.
    if (mAdapter != null) {
        if (!(mAdapter instanceof HeaderViewListAdapter)) {
            mAdapter = new HeaderViewListAdapter(mHeaderViewInfos, mFooterViewInfos, mAdapter);
        }
    …
    }
}

ListView의 addHeaderView의 내부를 들여다 보면 기존의 ListAdapter 대신에 HeaderViewListAdapter 어댑터 클래스를 사용하며 이 위젯은 래핑에 필요한 WrapperListAdapter 인터페이스를 상속하고 있으며 Adapter를 가지고 있는 껍데기가 된다. 기존의 Adapter를 HeaderViewListAdapter로 재생성(Wrapping)하면서 mAdapter를 파라미터로 넘겨주는 이유가 바로 이 때문이다. 따라서 HeaderViewListAdapter에서는 기존의 Adapter를 그대로 가지고 있으면서 화면에 상하단에 추가된 뷰를 노출시켜주는 함수를 더 가지고 있다. 다음으로 getView함수를 살펴보자.

public View getView(int position, View convertView, ViewGroup parent) {
    // Header (negative positions will throw an IndexOutOfBoundsException)
    int numHeaders = getHeadersCount();
    if (position < numHeaders) {
        return mHeaderViewInfos.get(position).view;
    }

    // Adapter
    final int adjPosition = position - numHeaders;
    int adapterCount = 0;
    if (mAdapter != null) {
        adapterCount = mAdapter.getCount();
        if (adjPosition < adapterCount) {
            return mAdapter.getView(adjPosition, convertView, parent);
        }
    }

    // Footer (off-limits positions will throw an IndexOutOfBoundsException)
    return mFooterViewInfos.get(adjPosition - adapterCount).view;
}

Header로 추가된 상단뷰의 수보다 position이 작은 경우에는 HeaderView를 리턴해주고 ListView에서 Row Item의 position은 Header 수 만큼 빼서 그려준다. 상하단에 추가된 뷰가 있을 경우, 그것을 보여주고 그게 아니라면 Adapter의 기본 로직으로 동작하게 된다.

HeaderRecyclerViewAdapter 구현하기
public abstract class HeaderRecyclerViewAdapter<T extends RecyclerView.ViewHolder> extends RecyclerView.Adapter<T> {

개발 중인 서비스에서 다양한 데이터 타입이나 뷰를 사용 한다면 일일이 대응되는 클래스(RecyclerView.Adapter 상속 받는)를 생성하기 보다는 HeaderRecyclerView를 사용하거나 구현 방법을 한번 보는 것을 추천한다. 목록을 보여주는 ListView와 RecyclerView는 기본 개념이 같이 때문에 내부 동작 또한 크게 차이는 없을 것이다.

달라진 것이 있다면 ListView에서 종종 findViewById의 비용을 줄이기 위해 사용하던 ViewHolder가 RecyclerView.ViewHolder로 추가되어 예전에는 Adapter에서 getView를 통해 View를 생성했다면 이제는 ViewHolder를 생성하고(onCreateViewHolder) 데이터 바인딩할 때(onBindViewHolder), 파라미터로 다시 받아 사용할 수 있다.

public class HeaderRecyclerViewAdapter extends RecyclerView.Adapter<RecyclerView.ViewHolder>{

  private int TYPE_HEADER = -1;
  private int TYPE_ROW_ITEM = -2;

…
  public void addHeaderView(View header) {
    mHeaderViews.add(header);
  }

  @Override
  public RecyclerView.ViewHolder onCreateViewHolder(ViewGroup parent, int viewType) {
    if (isHeaderPosition(position)) {
      return new ViewHolder(mHeaderViews.get(position));
    } else {
      return mInnerAdapter.onCreateViewHolder(parent, position);
    }
  }

  @Override
  public void onBindViewHolder(RecyclerView.ViewHolder holder, int position) {
    if (isHeaderPosition(position)) {
      //  bind HeaderViewHolder
    } else {
      //  bind ItemViewHolder
    }
  }

  @Override
  public int getItemViewType(int position) {
    // return 3 item type by position
  }
…

ListView의 addHeaderView 함수를 HeaderRecyclerViewAdapter에 추가한다. 그리고 RecyclerView.Adapter가 position과 ViewHolder의 타입에 따라 동작해야 하므로 위의 3가지의 함수가 동작에 있어서 가장 중요하다. 마찬가지로 만약에 FooterView를 RecyclerView에 추가하고 싶을 경우, 뷰의 식별자를 선언하고 ViewHolder를 생성하는 부분과 데이터를 바인딩하는 부분에 추가한다. 그리고 getItemViewType에서 화면상에 FooterView가 노출되는 position일 때 리턴해주면 된다. 마지막으로 앞으로 자주 사용할 RecyclerView와 많이 사용했던 ListView를 자유롭게 다뤄보자.

HangulParser – 한글 자소 조합과 분리

HangulParser는 Java와 Android 프로젝트에서 한글의 글자 하나를 자소(자음과 모음)로 분리하고, 자음과 모음을 하나의 글자로 조합 해주는 Java 라이브러리다. 간단한 안드로이드 앱을 개발하기 위해 찾아 보다가 한글 글자 하나는 유니코드 값을 가지고 초성, 중성, 종성으로 나눌 수 있는 문서를 읽고 만들게 되었다. 반대로 초성, 중성, 종성의 유니코드 값을 더하면 한 글자의 유니코드를 얻을 수 있다.

Reference App : 꽃별천지

Download

HangulParser Github

Java와 Android 프로젝트에서 빌드 툴로 Gradle을 사용하고 있다면 build.gradle 파일에 다음을 추가해서 빌드하면 쉽게 사용할 수 있다.

repositories {
  jcenter()
}

dependencies {
  compile 'com.github.kimkevin:hangulparser:1.0.0'
}

Usage

자소 분리 (Disassemble)

List disassemble(char hangul)

한 글자를 자소(초성, 중성, 종성)로 나누어 리스트로 반환한다.

Samples :

jasoList = HangulParser.getInstance().disassemble('한');
> [ㅎ, ㅏ, ㄴ]

예를 들어, ‘한’을 입력 값으로 넣어주면 결과는 [ㅎ,ㅏ,ㄴ]을 얻을 수 있다.

jasoList = HangulParser.getInstance().disassemble("한글");
> [ㅎ, ㅏ, ㄴ, ㄱ, ㅡ, ㄹ]

‘한글’을 입력할 경우에는 한 글자씩 분리해서 최종적으로는 ‘[ㅎ, ㅏ, ㄴ, ㄱ, ㅡ, ㄹ]’을 얻을 수 있다.

자소 결합 (assemble)

자소 리스트를 입력하면 조합해서 글자로 변환해준다.

public String assemble(List\<String\> jasoList)

Samples :

jasoList.add("ㅎ");
jasoList.add("ㅏ");
jasoList.add("ㄴ");
jasoList.add("ㄱ");
jasoList.add("ㅡ");
jasoList.add("ㄹ");

String hangul = HangulParser.getInstance().assemble(jasoList);
> 한글

‘[ㅎ, ㅏ, ㄴ, ㄱ, ㅡ, ㄹ]’을 입력값으로 넣어주면 결합해서 ‘한글’을 얻을 수 있다.

Proguard 난독화와 Crash Report Tool로 이슈 디버깅

프로젝트를 운영(유지보수)하면서 가장 중요하면서도 가장 기본이 되는 작업이 버그 리포트이다. 여기에선 Crash Report Tool로 Crashlytics를 사용했다. 그리고 회사 또는 팀의 정책, 작게는 개인에 따라 코드의 중요성이 높다면 난독화를 고민하게 되고 안드로이드에서는 무료인 Proguard 적용을 고려해보게 된다. (코드의 중요성이 낮더라도 한번 경험해보는 것도 좋다고 생각함) 하지만 난독화된 앱을 배포한 후 발생한 이슈를 디버깅하기 위해 Crash Report 콘솔에서 확인하면 이상한 문자를 접하게 된다.

Proguard 적용 전 이슈 발생했을 때

난독화가 되지 않은 앱을 디컴파일 해보면 소스 코드가 정상적으로 확인할 수 있다. 포함된 Android Support Library도 난독화가 되지 않은 상태이다.

그리고 앱에서 이슈를 발생시켜 보면 특정 함수에서 발생했으며 어떤 파일의 몇 번째 줄에서 발생했는데 ‘(MainActivity.java:21)을 통해서 알 수 있다.

public void forceCrash(View view) {
    throw new RuntimeException("This is a crash");
}

이슈는 다음과 같이 RuntimeException을 버튼 클릭 때 발생시키도록 만들었다.

Proguard 적용하기

프로젝트 생성 시 기본적으로 다음과 같이 build.gradle파일에 빌드 설정으로 다음과 같이 된다.

buildTypes {
    release {
        minifyEnabled false
        proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
    }
}

그리고 배포 시 Proguard 적용하기 위해서는 minifyEnabled를 true로 변경하면 된다.

buildTypes {
    release {
        minifyEnabled true
        proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
    }
}

ProGuard는 릴리스 모드(release)에서 빌드할 때는 적용되지만, 디버그 모드(debug)에서는 적용되지 않는다.

proguard를 적용해서 Signed APK파일을 만들고 디컴파일 했을 경우에는 위처럼 패키지부터 소스 파일까지 모두 특정 알파벳으로 난독화가 된 것을 확인할 수 있다.

앱에서 이슈를 발생시켜서 Crashlytics 콘솔에서 확인해보면 위에서 확인했던 이슈가 발생한 파일과 라인 수 부분이 Unknown Source로 변경되었다. 간단한 샘플 프로젝트이기 때문에 코드로 디버깅이 가능하지만 큰 규모의 프로젝트에서는 디버깅하는데 어려움을 겪을 수 있다.

그래서 proguard-rules.pro에 Proguard Rule을 추가하자.

-keepattributes SourceFile,LineNumberTable

다시 Proguard가 적용된 APK파일 생성하고 이슈를 발생 시켜보자.

Crashlytics를 확인하면 Unknown Source 대신에 이슈가 발생한 파일과 라인 수를 확인할 수 있다. 그리고 출력된 stack trace 오류에서 난독화된 패키지가 무엇인지 알고 싶다면 /outputs/mapping/release/mapping.txt 파일을 열어보자.

android.support.v4.app.Fragment -> android.support.v4.a.t:
    android.support.v4.util.SimpleArrayMap sClassMap -> aa
    java.lang.Object USE_DEFAULT_TRANSITION -> a
    int mState -> b
    android.view.View mAnimatingAway -> c
    int mStateAfterAnimating -> d
    android.os.Bundle mSavedFragmentState -> e
    android.util.SparseArray mSavedViewState -> f
    int mIndex -> g
    java.lang.String mWho -> h

Fragment가 속한 패키지는 android.support.v4.a.t로 변경된 것을 확인할 수 있으며 이에 속해 있는 변수나 함수들이 어떻게 변경되었는지 또한 확인할 수 있다. 난독화를 했다면 mapping.txt 파일은 보관해두고 만약 난독화된 코드의 stack trace를 디코딩하고 싶다면 mapping.txt를 이용해서 가능하기 때문이다.

retrace.bat -verbose mapping.txt stacktrace.txt > out.txt

out.txt파일에는 난독화된 코드의 stacktrace.txt가 해석된 stack trace가 출력된다.

ProGuard outputs the following files after it runs:

dump.txt
Describes the internal structure of all the class files in the .apk file
mapping.txt
Lists the mapping between the original and obfuscated class, method, and field names. This file is important when you receive a bug report from a release build, because it translates the obfuscated stack trace back to the original class, method, and member names. See Decoding Obfuscated Stack Traces for more information.
seeds.txt
Lists the classes and members that are not obfuscated
usage.txt
Lists the code that was stripped from the .apk

Proguard 사용하는 규칙

Proguard Manual – Proguard 사용 메뉴얼

-dontwarn [class_filter]
Specifies not to warn about unresolved references and other important problems at all. The optional filter is a regular expression; ProGuard doesn’t print warnings about classes with matching names. Ignoring warnings can be dangerous.
keep [,modifier,…] class_specification
Specifies classes and class members (fields and methods) to be preserved as entry points to your code.
-keepattributes [attribute_filter]
Specifies any optional attributes to be preserved. The attributes can be specified with one or more -keepattributes directives. The optional filter is a comma-separated list of attribute names that Java virtual machines and ProGuard support. Attribute names can contain ?, *, and ** wildcards, and they can be preceded by the ! negator.
-assumenosideeffects class_specification
Specifies methods that don’t have any side effects (other than maybe returning a value). In the optimization step, ProGuard will then remove calls to such methods, if it can determine that the return values aren’t used. With some care, you can also use the option to remove logging code.

Proguard를 적용하고 Signed Apk를 생성할 때, 가끔 발생하는 이슈들이 있는데, 그 중 하나가 Referenced Class를 찾을 수 없어서 IOException이 발생했기 때문이죠.

오류 메시지를 보면 Please correct the above warnings first로 발생한 Warning들을 고쳐달라고 합니다.

Proguard 적용 중에 왜 Warning이 발생했는지 궁금하시면 Problem while processing에서 확인할 수 있어요. 이때 위에 있는 규칙들 중에서 맞는 규칙을 찾아 proguard-rules.pro에 추가해준다.

-dontwarn okio.**

위의 이슈는 okio 패키지에 있는 파일들에 대해선 Warning을 뜨지 않도록 다음과 같이 설정을 해주면 된다.

-keep class com.example.classname
-keepattributes attribute

# examples
-keep class android.support.v4.app.** { *; }
-keep interface android.support.v4.app.** { *; }
-keep class android.support.v7.** { *; }
-keep interface android.support.v7.** { *; }

만약에 Class 변환 중에 이슈가 발생하거나 외부 라이브러리의 Proguard 적용을 원하지 않을 경우, 해당 패키지의 파일들을 Proguard가 적용되지 않도록 설정이 가능하다. 구글 라이브러리의 경우, Proguard가 불필요하기 때문에 Proguard적용에서 제외시켜주는게 좋다. 그리고 보통은 외부 라이브러리를 추가할 때, Proguard 적용시 주의사항으로 규칙들을 제공해주고 있으므로 잘 확인해서 추가만 해준다면 크게 문제가 발생하지 않는다.

decompile apk (APK 디컴파일하기)

가끔 진행하는 앱의 코드 난독화 확인을 위해서 기록해 두려고 한다. APK 디컴파일(Decompile)을 악용하지 말았으면 한다. 안드로이드 애플리케이션이 컴파일되면 .dex 파일이 생성된다. dex는 Dalvik Executable로 Dalvik에서 실행할 수 있는 파일이며 컴파일된 코드 파일을 의미한다.

Download dex2jar tool

1. dex파일을 jar(.class files)로 변환하기 위해서 dex2jar를 다운로드 한다.

2. Signed APK 파일의 확장명을 apk에서 zip으로 변경한다.

3. zip 압축을 풀면 classes.dex 파일을 다운받은 dex2jar 폴더로 복사한다.

$ ./d2j-dex2jar.sh classes.dex
sh: ./d2j-dex2jar.sh: Permission denied

4. 위처럼 dex2jar.sh 실행하면 권한 거부로 실행할 수 없게 된다.

$ chmod 764 *.sh
$ ./d2j-dex2jar.sh classes.dex
dex2jar classes.dex -> ./classes-dex2jar.jar

실행 권한을 변경한 후 다시 실행하면 classes-dex2jar.jar 파일이 생성된다.

Download JD_GUI

JD(Java Decompiler)-GUIjar파일 안에 있는 class파일들을 보기 위해서 다운로드 한다.

생성된 classes-dex2jar.jar를 열면 모든 class파일들을 볼 수 있다.

failed to find target with hash string ‘android-XX’ in Jenkins CI

compileSdkVersion, minSdkVersion, and targetSdkVersion

First of all, read this ‘Picking your compileSdkVersion, minSdkVersion, and targetSdkVersion’ to know what these mean for building your project.

compileSdkVersion, minSdkVersion, and targetSdkVersion come in: they control what APIs are available, what the required API level is, and what compatiblity modes are applied, respectively. – Ian Lake’s blog

I’ve updated android latest compileSdkVersion, targetSdkVersion and buildToolsVersion on my Android project a couple of days ago, installing the latest version of the Android platform as android-24 and buildTools as 24.0.1.

android {
    compileSdkVersion 24
    buildToolsVersion '24.0.1'

    defaultConfig {
        targetSdkVersion 24
….

What went wrong :

But Jenkins gives this error through slack-plugin as below.

Jenkins Bot Message on Slack

Check Console Output on Jenkins

It failed to find android sdk 24 in android-sdk-linux directory because compileSdkVersion sets 23 to the newest API 24. So you can check list installed SDK package so far via command line.

$ cd /opt/android-sdk-linux/platforms
$ ls
android-10  android-15  android-16  android-17  android-18  android-19  android-20  android-21  android-22  android-23  android-8

You will understand it’s no android-24 folder, to compile app by API level 24 so let’s add android-24 to platforms and also Android SDK Build-tools, version 24.0.1. See the package list for Android SDK Tools, Android SDK Platform-Tools, Android SDK Build-tools, Google Play services, Google Admob and etc.

How To Update SDK

$ cd /opt/android-sdk-linux/tools
$ ./android list sdk --all
Packages available for installation or update: 156
   1- Android SDK Tools, revision 25.1.7
   2- Android SDK Tools, revision 25.2.1 rc1
   3- Android SDK Platform-tools, revision 24.0.1
   4- Android SDK Build-tools, revision 24.0.1
   5- Android SDK Build-tools, revision 24
   6- Android SDK Build-tools, revision 23.0.3
   7- Android SDK Build-tools, revision 23.0.2
   8- Android SDK Build-tools, revision 23.0.1

………

  29- SDK Platform Android 7.0, API 24, revision 2
  30- SDK Platform Android 6.0, API 23, revision 3
  31- SDK Platform Android 5.1.1, API 22, revision 2
  32- SDK Platform Android 5.0.1, API 21, revision 2
  33- SDK Platform Android 4.4W.2, API 20, revision 2
  34- SDK Platform Android 4.4.2, API 19, revision 4
  35- SDK Platform Android 4.3.1, API 18, revision 3

……….

 108- Google APIs, Android API 23, revision 1
 109- Google APIs, Android API 22, revision 1
 110- Google APIs, Android API 21, revision 1

Now we need to install No.29, ‘SDK Platform Android 7.0, API 24, revision 2’, to build in Jenkins successfully. So try to install by commend line as below.

$ sudo ./android update sdk -a -u -t 29

Try build it again in Jenkins. Finally, the problem was fixed and build was finished successfully.