2026/02/27

오늘의 이야기



#스치니1000프로젝트 #재미 #행운기원 #Compose #Firebase

🎯 야 너 토요일마다 로또 확인하냐?
나도 맨날 "혹시나~" 하면서 봤거든 ㅋㅋ

근데 이제는 그냥 안 해
AI한테 맡겼어 🤖✨

그것도 구글 Gemini로다가!

그래서 앱 하나 만들었지
👉 "로또 예상번호 by Gemini" 🎱

AI가 분석해서 번호 딱! 뽑아줌
그냥 보고 참고만 하면 됨

재미로 해도 좋고…
혹시 모르는 거잖아? 😏


https://play.google.com/store/apps/details?id=com.billcorea.gptlotto1127




오늘의 이야기

이전 포스팅


이전에 작성했던 포스팅을 참고하여 인앱 결제를 구현했던 기억을 되살펴 보겠습니다.

https://billcorea.tistory.com/27







안드로이드 앱 만들기 구글 인앱결제 쉽게 따라 하기...


인앱 결제를 하기 위해서 오늘도 구글링을 하시는 분들께... 기본적은 헤맴을 줄여보기 위해서 정리를 해 둡니다. 인앱 결제를 하려면 일단, 할 일은 앱을 하나 만들어서 구글 플레이에 등록을


billcorea.tistory.com





이전 포스팅에서는 1회성 결제에 대한 구현을 살펴볼 수 있습니다. 이번에는 정기 결제를 구현해 보도록 하겠습니다. 이번 구현을 위해서 gradle 설정을 해 봅니다.


dependencies {

    ...
    
    implementation 'com.android.billingclient:billing:5.0.0'
    implementation 'com.google.code.gson:gson:2.9.0'
    
    ...

}


BillingClient


코드 구현은 다음과 같이 구현을 하였습니다. 코드 하나 구현해 두면 다음 프로젝트에서는 그대로 옮겨다 사용할 수 있습니다.


import android.app.Activity;
import android.content.Context;
import android.content.SharedPreferences;
import android.util.Log;
import android.widget.Toast;

import androidx.annotation.NonNull;
import androidx.annotation.Nullable;

import com.android.billingclient.api.AcknowledgePurchaseParams;
import com.android.billingclient.api.AcknowledgePurchaseResponseListener;
import com.android.billingclient.api.BillingClient;
import com.android.billingclient.api.BillingClientStateListener;
import com.android.billingclient.api.BillingFlowParams;
import com.android.billingclient.api.BillingResult;
import com.android.billingclient.api.ConsumeParams;
import com.android.billingclient.api.ConsumeResponseListener;
import com.android.billingclient.api.Purchase;
import com.android.billingclient.api.PurchaseHistoryRecord;
import com.android.billingclient.api.PurchaseHistoryResponseListener;
import com.android.billingclient.api.PurchasesResponseListener;
import com.android.billingclient.api.PurchasesUpdatedListener;
import com.android.billingclient.api.SkuDetails;
import com.android.billingclient.api.SkuDetailsParams;
import com.android.billingclient.api.SkuDetailsResponseListener;
import com.billcoreatech.daycnt415.R;
import com.billcoreatech.daycnt415.util.KakaoToast;
import com.billcoreatech.daycnt415.util.StringUtil;

import org.json.JSONException;
import org.json.JSONObject;

import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.Date;
import java.util.List;

public class BillingManager implements PurchasesUpdatedListener, ConsumeResponseListener {
    String TAG = "BillingManager" ;
    BillingClient mBillingClient ;
    Activity mActivity ;
    public List<SkuDetails> mSkuDetails ;

    public enum connectStatusTypes { waiting, connected, fail, disconnected }
    public connectStatusTypes connectStatus = connectStatusTypes.waiting ;
    private ConsumeResponseListener mConsumResListnere ;

    String punchName = "210414_monthly_bill_999";
    String payType = BillingClient.SkuType.SUBS ;

    SharedPreferences option ;
    SharedPreferences.Editor editor ;

    public BillingManager (Activity _activity) {
        mActivity = _activity ;
        option = mActivity.getSharedPreferences("option", mActivity.MODE_PRIVATE);
        editor = option.edit();
        mBillingClient = BillingClient.newBuilder(mActivity)
                .setListener(this)
                .enablePendingPurchases()
                .build() ;
        mBillingClient.startConnection(new BillingClientStateListener() {
            @Override
            public void onBillingSetupFinished(@NonNull BillingResult billingResult) {
                Log.e(TAG, "respCode=" + billingResult.getResponseCode() ) ;
                if (billingResult.getResponseCode() == BillingClient.BillingResponseCode.OK) {
                    connectStatus = connectStatusTypes.connected ;
                    Log.e(TAG, "connected...") ;
                    purchaseAsync();
                    getSkuDetailList();

                } else {
                    connectStatus = connectStatusTypes.fail ;
                    Log.i(TAG, "connected... fail ") ;
                }
            }

            @Override
            public void onBillingServiceDisconnected() {
                connectStatus = connectStatusTypes.disconnected ;
                Log.i(TAG, "disconnected ") ;
            }
        });

    }

    /**
     * 정기 결재 소모 여부를 수신 : 21.04.20 1회성 구매의 경우는 결재하면 끝임.
     * @param billingResult
     * @param purchaseToken
     */
    @Override
    public void onConsumeResponse(@NonNull BillingResult billingResult, @NonNull String purchaseToken) {
        if (billingResult.getResponseCode() == BillingClient.BillingResponseCode.OK) {
            Log.i(TAG, "사용끝 + " + purchaseToken) ;
            return ;
        } else {
            Log.i(TAG, "소모에 실패 " + billingResult.getResponseCode() + " 대상 상품 " + purchaseToken) ;
            return ;
        }
    }

    public int purchase(SkuDetails skuDetails) {
        BillingFlowParams flowParams = BillingFlowParams.newBuilder()
                .setSkuDetails(skuDetails)
                .build();
        return mBillingClient.launchBillingFlow(mActivity, flowParams).getResponseCode();
    }

    public void purchaseAsync() {
        Log.e(TAG, "--------------------------------------------------------------");

        mBillingClient.queryPurchasesAsync(payType, new PurchasesResponseListener() {
            @Override
            public void onQueryPurchasesResponse(@NonNull BillingResult billingResult, @NonNull List<Purchase> list) {
                Log.e(TAG, "onQueryPurchasesResponse=" + billingResult.getResponseCode()) ;
                SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
                if (list.size() < 1) {
                    editor = option.edit();
                    editor.putBoolean("isBill", false);
                    editor.commit();
                } else {
                    for (Purchase purchase : list) {
                        Log.e(TAG, "getPurchaseToken=" + purchase.getPurchaseToken());
                        for (String str : purchase.getSkus()) {
                            Log.e(TAG, "getSkus=" + str);
                        }
                        Date now = new Date();
                        now.setTime(purchase.getPurchaseTime());
                        Log.e(TAG, "getPurchaseTime=" + sdf.format(now));
                        Log.e(TAG, "getQuantity=" + purchase.getQuantity());
                        Log.e(TAG, "getSignature=" + purchase.getSignature());
                        Log.e(TAG, "isAutoRenewing=" + purchase.isAutoRenewing());

                        editor = option.edit();
                        editor.putBoolean("isBill", purchase.isAutoRenewing());
                        editor.commit();
                    }
                }
            }
        });

        mBillingClient.queryPurchaseHistoryAsync(payType, new PurchaseHistoryResponseListener() {
            @Override
            public void onPurchaseHistoryResponse(@NonNull BillingResult billingResult, @Nullable List<PurchaseHistoryRecord> list) {
                if (billingResult.getResponseCode() == 0) {
                    SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
                    for(PurchaseHistoryRecord purchase : list) {
                        Log.e(TAG, "getPurchaseToken=" + purchase.getPurchaseToken());
                        for (String str : purchase.getSkus()) {
                            Log.e(TAG, "getSkus=" + str);
                        }
                        Date now = new Date();
                        now.setTime(purchase.getPurchaseTime());
                        Log.e(TAG, "getPurchaseTime=" + sdf.format(now));
                        Log.e(TAG, "getQuantity=" + purchase.getQuantity());
                        Log.e(TAG, "getSignature=" + purchase.getSignature());

                        if (payType.equals(BillingClient.SkuType.INAPP)) {
                            ConsumeParams params = ConsumeParams.newBuilder()
                                    .setPurchaseToken(purchase.getPurchaseToken())
                                    .build();
                            mBillingClient.consumeAsync(params, BillingManager.this);
                        }

                    }
                }
            }
        });
    }

    public void getSkuDetailList() {
        List<String> skuIdList = new ArrayList<>() ;
        skuIdList.add(punchName);

        SkuDetailsParams.Builder params = SkuDetailsParams.newBuilder();
        params.setSkusList(skuIdList).setType(payType);
        mBillingClient.querySkuDetailsAsync(params.build(), new SkuDetailsResponseListener() {
            @Override
            public void onSkuDetailsResponse(@NonNull BillingResult billingResult, @Nullable List<SkuDetails> skuDetailsList) {
                if (billingResult.getResponseCode() != BillingClient.BillingResponseCode.OK) {
                    Log.i(TAG, "detail respCode=" + billingResult.getResponseCode()) ;
                    return ;
                }
                if (skuDetailsList == null) {
                    KakaoToast.makeToast(mActivity, mActivity.getString(R.string.msgNotInfo), Toast.LENGTH_LONG).show();
                    return ;
                }
                Log.i(TAG, "listCount=" + skuDetailsList.size());
                for(SkuDetails skuDetails : skuDetailsList) {
                    Log.i(TAG, "\n" + skuDetails.getSku()
                            + "\n" + skuDetails.getTitle()
                            + "\n" + skuDetails.getPrice()
                            + "\n" + skuDetails.getDescription()
                            + "\n" + skuDetails.getFreeTrialPeriod()
                            + "\n" + skuDetails.getIconUrl()
                            + "\n" + skuDetails.getIntroductoryPrice()
                            + "\n" + skuDetails.getIntroductoryPriceAmountMicros()
                            + "\n" + skuDetails.getOriginalPrice()
                            + "\n" + skuDetails.getPriceCurrencyCode()) ;
                }
                mSkuDetails = skuDetailsList ;

            }
        });
    }

    /**
     * @param billingResult
     * @param purchases
     */
    @Override
    public void onPurchasesUpdated(@NonNull BillingResult billingResult, @Nullable List<Purchase> purchases) {

        if (billingResult.getResponseCode() == BillingClient.BillingResponseCode.OK && purchases != null) {
            Log.i(TAG, "구매 성공>>>" + billingResult.getDebugMessage());
            JSONObject object = null ;
            String pID = "" ;
            String pDate = "" ;

            for(Purchase purchase : purchases) {
                // 정기구독의 경우는 구매처리후 구매 확인을 해 주어야 취소가 되지 않음.
                handlePurchase(purchase);
                Log.i(TAG, "성공값=" + purchase.getPurchaseToken()) ;
                try {
                    Log.e(TAG, "getOriginalJson=" + purchase.getOriginalJson());
                    object = new JSONObject(purchase.getOriginalJson());
                    String sku = "";
                    for (String str : purchase.getSkus()) {
                        sku = str ;
                        Log.e(TAG, "SKU=" + sku);
                    }
                    pID = object.getString("purchaseToken");
                    pDate = StringUtil.getDate(object.getLong("purchaseTime"));
                    if (sku.equals(punchName)) {                      
                        editor.putLong("billTimeStamp", object.getLong("purchaseTime"));
                        editor.putBoolean("isBill", object.getBoolean("autoRenewing"));
                        editor.putString("token", purchase.getPurchaseToken());
                    }
                    editor.commit();

                } catch (JSONException e) {
                    e.printStackTrace();
                }
            }
        } else if (billingResult.getResponseCode() == BillingClient.BillingResponseCode.USER_CANCELED) {
            Log.i(TAG, "결제 취소");
            editor = option.edit();
            editor.putBoolean("isBill", false);
            editor.commit();
        } else {
            Log.i(TAG, "오류 코드=" + billingResult.getResponseCode()) ;
            editor = option.edit();
            editor.putBoolean("isBill", false);
            editor.commit();
        }
    }

    void handlePurchase(Purchase purchase) {
        if (purchase.getPurchaseState() == Purchase.PurchaseState.PURCHASED) {
            if (!purchase.isAcknowledged()) {
                AcknowledgePurchaseParams acknowledgePurchaseParams =
                        AcknowledgePurchaseParams.newBuilder()
                                .setPurchaseToken(purchase.getPurchaseToken())
                                .build();
                mBillingClient.acknowledgePurchase(acknowledgePurchaseParams, new AcknowledgePurchaseResponseListener() {
                    @Override
                    public void onAcknowledgePurchaseResponse(@NonNull BillingResult billingResult) {
                        Log.e(TAG, "getResponseCode=" + billingResult.getResponseCode());
                    }
                });
            }
        }
        //PENDING
        else if (purchase.getPurchaseState() == Purchase.PurchaseState.PENDING) {
            //구매 유예
            Log.e(TAG, "//구매 유예");
        }
        else {
            //구매확정 취소됨(기타 다양한 사유...)
            Log.e(TAG, "//구매확정 취소됨(기타 다양한 사유...)");
        }    
    }

}

정기결제 예시


정기결제를 실행하는 화면은 다음과 왼쪽 그림과 같이 처리가 됩니다.

이렇게 정기 결제된 경우에는 다른 처리를 할 필요는 없습니다.

다만, 정기결제가 유지되고 있는 지를 확인하는 처리가 필요했습니다.

그래서 아래 코드와 같이 queryPurchaseAsync을 호출해서
구매된 내역을 확인하여 그 값 중에

isAutoRenewing()의 값이 true 가 오는 지를 보고 값이 true 인 경우는 정기결제가 유지되고 있음을 확인할 수 있었습니다.

list 값이 오지 않거나, false 가 오면 정기 구매가 되지 않고 있다고 보고 필요한 처리를 하면 됩니다.














mBillingClient.queryPurchasesAsync(payType, new PurchasesResponseListener() {
            @Override
            public void onQueryPurchasesResponse(@NonNull BillingResult billingResult, @NonNull List<Purchase> list) {
                Log.e(TAG, "onQueryPurchasesResponse=" + billingResult.getResponseCode()) ;
                SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
                if (list.size() < 1) {
                    editor = option.edit();
                    editor.putBoolean("isBill", false);
                    editor.commit();
                } else {
                    for (Purchase purchase : list) {
                        Log.e(TAG, "getPurchaseToken=" + purchase.getPurchaseToken());
                        for (String str : purchase.getSkus()) {
                            Log.e(TAG, "getSkus=" + str);
                        }
                        Date now = new Date();
                        now.setTime(purchase.getPurchaseTime());
                        Log.e(TAG, "getPurchaseTime=" + sdf.format(now));
                        Log.e(TAG, "getQuantity=" + purchase.getQuantity());
                        Log.e(TAG, "getSignature=" + purchase.getSignature());
                        Log.e(TAG, "isAutoRenewing=" + purchase.isAutoRenewing());

                        editor = option.edit();
                        editor.putBoolean("isBill", purchase.isAutoRenewing());
                        editor.commit();
                    }
                }
            }
        });


테스트 구매의 경우는 5분마다 한 번씩 갱신되므로 5분 뒤에 다시 확인하는 처리에 대한 검증을 해 볼 수 있습니다.
기타 추가적인 작업을 해야 할 것들이 남아 있는 것으로 생각이 됩니다. 아래 링크를 참고해서 추가 구현을 해 보도록 하겠습니다.

https://developer.android.com/google/play/billing/subscriptions?hl=ko







정기 결제 판매  |  Google Play 결제 시스템  |  Android Developers


알림: 2021년 11월 1일부터는 기존 앱의 모든 업데이트에도 결제 라이브러리 버전 3 이상이 요구됩니다. 자세히 알아보기 정기 결제 판매 이 주제에서는 갱신 및 만료와 같은 정기 결제 수명 주기


developer.android.com





이것으로 인앱 결제 정기결제에 대한 이해를 해 보았습니다.

정기구독의 경우 구독취소가 되지 않도록 하기 위해서는 구매한 것을 확인해 주는 처리를 꼭 거쳐야 합니다. 그렇지 않을 경우에는 구독했던 것이 취소되어 소모가 되지 않기 때문입니다.


if (purchase.getPurchaseState() == Purchase.PurchaseState.PURCHASED) {
if (!purchase.isAcknowledged()) {
AcknowledgePurchaseParams acknowledgePurchaseParams =
AcknowledgePurchaseParams.newBuilder()
.setPurchaseToken(purchase.getPurchaseToken())
.build();
mBillingClient.acknowledgePurchase(acknowledgePurchaseParams, new AcknowledgePurchaseResponseListener() {
@Override
public void onAcknowledgePurchaseResponse(@NonNull BillingResult billingResult) {
Log.e(TAG, "getResponseCode=" + billingResult.getResponseCode());
}
});
}
}
//PENDING
else if (purchase.getPurchaseState() == Purchase.PurchaseState.PENDING) {
//구매 유예
Log.e(TAG, "//구매 유예");
}
else {
//구매확정 취소됨(기타 다양한 사유...)
Log.e(TAG, "//구매확정 취소됨(기타 다양한 사유...)");
}


코드공유


여기까지 구현된 소스는 github에서 참고해 보시기 바랍니다.

https://github.com/nari4169/daycnt415







GitHub - nari4169/daycnt415


Contribute to nari4169/daycnt415 development by creating an account on GitHub.


github.com





마치며


테스트는 앱을 내부테스트로 게시한 이후에 진행하세요. 꼭이요~~~

p.s 이글은 예전에 포스팅 했던 내용을 다시 수정해서 올렸습니다.





오늘의 이야기


#스하리1000명프로젝트

스치니들!
내가 만든 이 앱은, 내 폰에 오는 알림 중에서 중요한 키워드가 있는 경우
등록해둔 친구에게 자동으로 전달해주는 앱이야 📲

예를 들어, 카드 결제 알림을 와이프나 자녀에게 보내주거나
이번 달 지출을 달력처럼 확인할 수도 있어!

앱을 함께 쓰려면 친구도 설치 & 로그인해줘야 해.
그래야 친구 목록에서 서로 선택할 수 있으니까~
서로 써보고 불편한 점 있으면 알려줘 🙏

👉 https://play.google.com/store/apps/details?id=com.nari.notify2kakao





오늘의 이야기

 


google map platform 에서 온 메일



 


오늘은 google map platform 에서 알려온 메일에 대해서 잠시 보고 갈까 한다.  앱을 개발 하다 보니 google map 을 활용하게 되는 데, 이런 메일이 오다니... 


 


메일의 본문은 아래와 같이 읽어 보게 되었다. 


 


--- 메일 번역문 ---


일부 프로젝트는 Android용 Maps SDK를 사용합니다. 개선된 렌더러 및 기타 기능을 테스트하려면 새 업그레이드를 시도하십시오.

안녕하세요, Google Maps Platform 고객입니다.


Google Play 서비스 를 통해 제공되는 Android용 Maps SDK (버전 18.0.x)  주요 업데이트 에는 점진적 출시를 통해 Android 기기*의 기본 렌더러가 될 새로운 지도 렌더러가 포함되어 있음을 알려 드립니다. 빠르면 2022년 6월 .


새 렌더러가 기본값이 되면 앱은 Android용 Maps SDK 버전 18.0.x 에 대해 빌드되었는지 여부에 관계없이 새 렌더러를 사용합니다.


새로운 맵 렌더러(SDK 최신 버전: 18.0.2)로 업데이트하여 새 렌더러가 Android 기기*의 기본 렌더러가 되기 전에 앱과 함께 새 SDK 및 렌더러를 테스트하고 배포하세요.


새로운 SDK를 사용하여 앱에 타일을 제공하고 클라이언트 장치에서 지도를 렌더링하는 방법을 개선했습니다. 따라서 Android 장치는 다음과 같은 이점이 있습니다.



  • 네트워크 부하, 처리 요구 사항 및 메모리 소비 감소.

  • 더 나은 애니메이션과 더 부드러운 이동 및 확대/축소를 위한 향상된 제스처 처리.

  • 보다 유동적인 전환 및 명확하게 배치된 지도 레이블.

  • Android의 향후 지도 혁신을 위해 더욱 안정적이고 개선된 사용자 경험.


새 SDK에는 모바일에서 클라우드 기반 지도 스타일링의 일반 가용성도 포함됩니다. Android용 Maps SDK 의 지도에 대한 클라우드 기반 지도 스타일 사용 은 Dynamic Maps SKU 로 청구됩니다 . 가격 및 포함된 기능에 대한 자세한 내용은 Cloud Customization 설명서를 참조하십시오.


* 데이터 저장 공간이 2GB 이상인 Android 5.0(Lollipop) 이상의 모든 장치는 새 렌더러를 사용합니다. 다른 모든 장치는 추후 공지가 있을 때까지 레거시 렌더러를 계속 사용합니다.


내가 무엇을해야 하나?


앱의 문제를 예상하고 방지하려면 다음을 수행하는 것이 좋습니다 .



  1. 종속 항목에 버전 18.0.x를 지정합니다( 예시 ).

  2. 코드에서 새 렌더러를 지정합니다( 지침 ).

  3. 2022년 6월 에 새로운 SDK 점진적 출시가 시작되기 전에 새 렌더러를 기반으로 앱의 새 버전을 테스트, 빌드 및 출시하세요. 새 렌더러를 사용하는 방법에 대한 지침은 새 맵 렌더러 설명서를 참조하세요 .


새 SDK 또는 새 렌더러를 사용하는 앱에서 문제가 발생하면 Android용 Maps SDK 지원 옵션을 확인 하고 문제가 해결될 때까지 사용자에 대한 앱 배포를 일시 중지하는 것이 좋습니다.


아래 나열된 프로젝트 에서 지난 4주 동안 Android용 Maps SDK를 사용하고 있는 것으로 확인되었습니다.


 


--- 번역문 끝 ---


 


말인즉슨 내가 만든앱에서 google map 을 사용하고 있고, 2022.6월이 지나면 새로운 renderer 을 사용하게 될 것이니 그전에 미리 sdk 버전을 18.0.x 버전으로 upgrade 하라는 말이다.  구글에서 새로운 renderer 를 사용하게 되면 4가지 정도 이상의 이점이 있다니, 해야지 얼른 내가 만들어 playstore 에 게시한 앱들을 살펴 봐야 겠다. 


 


 





오늘의 이야기

꿀벌 BumbleBee 에서 다시 Chipmunk 다람쥐 버전으로 upgrade 을 진행 했고, 다시 dolpin 돌고래 로 나아가기를 하고 있는 것 같다. 


 


개발자 입장에서는 안정화된 버전인 BumbleBee 버전을 사용하는 것이 좋은 것 같기는 하나, 뭐 어떤 가 얼리아답터 처럼 미리 접해 보는 것도 나쁘지 않을 것 같아서 오늘은 귀여운(?) Chipmunk 다람쥐 에 대해서 살펴 보고자 한다. 


chipmunk 다람쥐 버전 이미지



 


dolphin 이미지



 


하지만 아직 오류가 많아 보인다.  개발자 입장에서는 안정된 버전으로 작업을 하는 게 좋은 거지... 시작 하자 마자 오류가 나는 건 사용하기가 아직 이르다...


 


IDE 에러 메시지



 


plugin 때문에 어떤 오류가 ...



알 수 없다... 아직은...


 


일단 아쉬운 데로 Disable plugin 을 클릭해서 NDK 사용을 하지 않는 것으로 했더니 정리가 된 것 같기는 하다. 아직 NDK사용이 뭔지 모르는 나에게 그만...


 


다음은 Chipmuck 버전에서 나오는 변화된 것에 대한 내용 인데 , 


 


Gradle Managed Virtual Devices


자동화된 계측 테스트에 Android 가상 장치를 사용할 때 일관성, 성능 및 안정성을 개선하기 위해 Gradle 관리 가상 장치를 도입합니다. 이 기능을 사용하면 빌드 시스템이 자동화된 테스트를 실행하기 위해 해당 장치를 완전히 관리(즉, 생성, 배포 및 해체)하는 데 사용하는 프로젝트의 Gradle 파일에서 가상 테스트 장치를 구성할 수 있습니다.
 
모듈 수준 build.gradle 파일에서 Gradle이 앱 테스트에 사용할 가상 기기를 지정할 수 있습니다. 다음 코드 샘플은 API 레벨 29를 실행하는 Pixel 2를 Gradle 관리 기기로 생성합니다.


 


android {
testOptions {
devices {
pixel2api29 (com.android.build.api.dsl.ManagedVirtualDevice) {
// Use device profiles you typically see in
// Android Studio
device = "Pixel 2"
apiLevel = 29
// You can also specify "aosp" if you don't require
// Google Play Services.
systemImageSource = "google"
abi = "x86"
}
}
}
}

구성한 Gradle 관리 기기를 사용하여 테스트를 실행하려면 다음 명령어를 사용하세요.


 


gradlew
pixel2api29DebugAndroidTest

 


Automated Test Deices


 


Gradle 관리 장치는 계측 테스트를 실행할 때 CPU 및 메모리 리소스를 줄이도록 최적화된 ATD(자동 테스트 장치)라는 새로운 유형의 에뮬레이터 장치를 지원합니다.
 
Gradle 관리 장치와 함께 ATD 이미지를 사용하려면 아래와 같이 "atd" 이미지를 지정합니다.


android {
testOptions {
devices {
pixel2api29 (com.android.build.api.dsl.ManagedVirtualDevice) {
// Use device profiles you typically see in Android Studio
device = "Pixel 2"
// ATDs currently support only API level 30.
apiLevel = 30
// You can also specify "google-atd"
// if you require Google Play Services.
systemImageSource = "aosp-atd"
abi = "x86"
}
}
}
}

Run tests faster with Test Sharding




Gradle Managed Devices를 사용하여 테스트를 실행할 때 이제 테스트 샤딩을 활성화할 수 있습니다. 이를 통해 병렬로 실행되는 샤드라고 하는 여러 동일한 가상 장치 인스턴스에 테스트 스위트의 테스트를 배포할 수 있습니다. 테스트 샤딩을 활용하면 자동 테스트 장치를 사용하여 완화할 수 있는 추가 계산 리소스 비용으로 전체 테스트 실행 시간을 줄이는 데 도움이 될 수 있습니다. 주어진 테스트 실행에서 사용하려는 샤드 수를 설정하려면 gradle.properties 파일에서 다음을 설정합니다.



android.experimental.androidTest.numManagedDeviceShards=

 


미리보기 download 는 여기서 



 


Android Studio Preview  |  Android Developers


Get early access to the latest features and improvements in Android Studio.


developer.android.com




 


이만 새로운 android studio 에 대해서 살펴 보았다.


 





오늘의 이야기


#스하리1000명프로젝트,
A volte è difficile parlare con i lavoratori stranieri, vero?
Ho realizzato una semplice app che aiuta! Scrivi nella tua lingua e gli altri lo vedono nella loro.
Si traduce automaticamente in base alle impostazioni.
Super pratico per chat facili. Dai un'occhiata quando ne hai la possibilità!
https://play.google.com/store/apps/details?id=com.billcoreatech.multichat416




오늘의 이야기

https://medium.com/@umairkhalid786/splash-screen-api-android-701cfaaf7b70



 


Splash Screen API android


Hey folks, I hope you are doing great, and writing beautiful android apps.


medium.com




오늘은 medium 에서 만난 글 하나를 소개 하고자 한다. splash screen api 에 대한 부분이다. 예전에는 splash 화면을 만들기 위해서 acitivy 하나를 추가해서 만들고 해당 activity 을 실행하게 해서 구현했던 기억이 있다. 


 


https://billcorea.tistory.com/45



 


안드로이드 앱 로딩 페이지 (Splash) 하나 쯤 만들기...


앱을 만들다 보니 로딩 화면에서 광고문구등을 넣어서 사용하고 싶은 요청이 있다. 이런건 어떻게 ? 그냥 쉬운 생각으로 빈 activity 을 만들어서 잠시 보여주고 그냥 닫아 주면 되지 않을까 ? 그래


billcorea.tistory.com




 


이때는 이렇게 만들었는 데 말이다.  android 가 12로 올라가면서 api 가 추가 되었다.  저 위에 글쓴이의 말은 이해가 될 것 같기도 하다가. 그렇지 않기도 해서 여기 저기 찾다가 구현을 해 보았다. 


 


먼저. gradle 에 추가할 부분은 다음과 같이


dependencies {

....

implementation 'androidx.core:core-splashscreen:1.0.0-beta01'
implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.4.1'

.....
}

viewmodel 은 왜 들어가야 하는 가 ? 그것 아직 잘 이해가 되지는 않았다. 뭐 그래도 필요한 듯 하여...


 


다음은 splah 화면으로 사용할 theme 을 추가 한다. res / values 폴더에 splash_theme.xml 로 


<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.App.Starting" parent="Theme.SplashScreen">
<!-- I will rather have a splash screen with animated drawable icon
<item name="windowSplashScreenBackground">@color/purple_200</item>
-->
<item name="windowSplashScreenAnimatedIcon">@drawable/dice_1</item>
<item name="windowSplashScreenAnimationDuration">100</item>
<item name="postSplashScreenTheme">@style/Theme.KotlinExam0115</item>
</style>
</resources>

여기서 볼껀


windowSplashScreenBackground 을 사용하면 배경색 지정이 된다는 것이다. 


windowSplashScreenAnimatedIcon 을 이미지 아이콘을 지정하는 것이다. 저기서 지정한 dice_1 은 샘플 코딩 하다가 만든 png 파일이다. 


windowSplashScreenAnimationDuration 은 지속시간을 말하는 것이고


postSplashScreenTheme 는 내 앱 theme 을 지정해 주었다. (ex : Theme.KotlinExample0115 는 내가 만든 앱의 기본 style theme 명칭임)


 


다음은 acitivy 의 구현 부분 인데...


import android.animation.ObjectAnimator
import android.os.Bundle
import android.util.Log
import android.view.View
import android.view.animation.AnticipateInterpolator
import androidx.appcompat.app.AppCompatActivity
import androidx.core.animation.doOnEnd
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import kotlin.concurrent.thread

class MainActivity : AppCompatActivity() {

var TAG:String = "MainActivity"

override fun onCreate(savedInstanceState: Bundle?) {
val splashScreen = installSplashScreen()
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)

splashScreen.apply {
setOnExitAnimationListener { sp ->
sp.iconView.animate().rotation(180F).duration = 3000L
val slideUp = ObjectAnimator.ofFloat(
sp.iconView,
View.TRANSLATION_Y,
0f,
-sp.iconView.height.toFloat()
)
slideUp.interpolator = AnticipateInterpolator()
slideUp.doOnEnd {
sp.remove()
}
slideUp.start()
}
}
}
}

이 정도의 구현... 먼저 splashScreen 을 선언해 주고. 아래 에서 일정시간 동안 동작하고 꺼지는 화면을 구현 하도록 만들어 주는 정도가 되면 될 것 같다.   안드로이드 개발자 가이드는 아래 링크를 참고 하였다.


 


https://developer.android.com/guide/topics/ui/splash-screen



 


Splash screens  |  Android Developers


Splash screens Important: If you have previously implemented a custom splash screen in Android 11 or lower, you'll need to migrate your app to the SplashScreen API to ensure that it displays correctly in Android 12 and higher. For instructions, see Migra


developer.android.com




 


 



실행영상


 


실행되는 영상 이미지는 이렇게 동작을 한다.  저 주사위 이미지가 나왔다가. 사라지는 모양으로... 응용해 보면 되지 않을 가 ?


 


splash screen sample



 


이것으로 오늘은 정리 끝.





오늘의 이야기

오늘은 admob 에서 온 메일 이야기를 한가지 해 둘까 한다.  아마도 앱에 달리는 광고 때문일 것 같다.


 











제목 : Google Play 및 Android 변경사항을 지원하기 위한 핵심 GMA SDK 업데이트
Google Play 및 Android 변경사항을 지원하기 위한 권장 업데이트

 


메일 본문



구글의 보안정책이 강화 되면서 광고 게시와 관련된 것들도 강화 되고 있는 것 같은 생각이 든다. 앞으로는 앱에 광고판 달아서 수입을 얻기에는 어려운 시간이 될 것 같은...


 


아직 잘 모르는 부분이라서 오늘 부터는 적어두고 찾아 보려고 한다. 


 


1. 타겟팅


 


https://developers.google.com/admob/android/targeting#child-directed_setting



 


타겟팅  |  Android  |  Google Developers


타겟팅 This guide explains how to provide targeting information to an ad request. For a working example, download the Android API Demo app. Download API Demo Prerequisite RequestConfiguration RequestConfiguration is an object that collects targeting inf


developers.google.com




먼저 찾은 건 앱 사용자의 범위에 따라서 광고 대상 범위를 정하는 문제 일 것 같다.  어린이 대상인 앱일 때...  내가 만들고 있는 앱들은 그런 경우가 없지만, 혹시나... 전연령으로 배포가 가능한 앱일 경우... 위 링크의 타켓팅 설정을 하지 않을 때, 아마도 광고 제한이 있을 것으로 생각이 된다. 


 


TAG_FOR_CHILD_DIRECTED_TREATMENT_TRUE  그래서 아동용일때 설정은 이렇게 해 두어야 하고... 아닐 때는 FALSE 을 선택해야 하고...


 


 


2. SDK 버전 확인 


 


https://support.google.com/admob/answer/11402075



 


Google Play 및 Android 변경사항에 맞게 앱 준비하기 - Google AdMob 고객센터


도움이 되었나요? 어떻게 하면 개선할 수 있을까요? 예아니요


support.google.com




implementation 'com.google.android.gms:play-services-ads:20.5.0'

광고 빌드 버전은 20.5.0 이상을 지원해야 한다고 ... 난 이미 그렇게 하고 있으니... 일단 pass ?


 


 


 


3. 구글 가이드 따라 정리 ?


 


-- 아래는 복사한 글 ---


 


https://support.google.com/admob/answer/11402075



 


Google Play 및 Android 변경사항에 맞게 앱 준비하기 - Google AdMob 고객센터


도움이 되었나요? 어떻게 하면 개선할 수 있을까요? 예아니요


support.google.com




Google Play 및 Android 변경사항에 맞게 앱 준비하기






최근에 Google Play와 Android에서 몇 가지 새로운 정책 업데이트 사항과 기술 관련 변경사항을 발표했습니다. 다음 권장사항을 검토하여 앱이 최신 상태이고 규정을 준수하는지 확인하세요. 


Google Play의 데이터 보안 섹션 정보 제공


2021년 5월에 Google Play에서는 개발자가 앱의 사용자 데이터 수집, 공유 및 보안 관행을 공개하는 새로운 데이터 보안 섹션을 발표했습니다. 


조만간 Play Console에서 양식을 작성하여 내 앱의 개인 정보 보호 및 보안 관행에 대해 Google Play에 알려야 합니다. 이 정보는 나중에 Google Play 사용자가 내 앱을 다운로드하기 전에 사용자 데이터의 수집 및 공유 방식을 파악할 수 있도록 스토어 등록정보에 표시됩니다.


Google Play의 데이터 보안 섹션 관련 내용과 Google Play의 데이터 공개 요건에 대비하는 방법에 대해 자세히 알아보세요.


새 앱 세트 ID를 지원하도록 SDK 업데이트 


Android 12 기기부터 사용자가 Android 설정에서 맞춤설정을 선택 해제하면 Google Play에서 광고 ID를 삭제합니다. 또한 Google Play에서는 동일한 조직이 소유한 여러 앱에 걸쳐 개인 정보가 보호되는 방식으로 사용량과 행동을 상호 연관시켜주는 앱 세트 ID를 도입했습니다. 


맞춤설정을 선택 해제한 사용자에게 보고 및 사기 방지 기능을 지원하려면 새 앱 세트 ID를 지원하는 Google 모바일 광고(GMA) SDK 20.5.0 이상으로 업데이트하시기 바랍니다.


광고 ID 권한을 위한 앱 업데이트


대상 API 수준을 31(Android 12)로 업데이트하는 앱에서 광고 ID를 사용하려면 Android 매니페스트 파일에서 Google Play 서비스 일반 권한을 선언해야 합니다. 이 권한을 선언하지 않고 Android 12를 타겟팅하는 앱의 경우 광고 ID가 자동으로 삭제되고 0으로 구성된 문자열로 대체됩니다. 


GMA SDK 20.4.0 이상으로 앱을 업데이트하면 자동으로 권한이 선언됩니다. GMA SDK 20.4.0 이상을 사용 중이면 별도의 조치를 취하지 않아도 광고 ID를 계속 사용할 수 있습니다. 


사용 중지 방법 등 새 권한에 대해 자세히 알아보려면 Play Console 고객센터를 참고하세요.


Google Play 가족 정책의 업데이트 내용 검토


Google Play에서 가족 정책 요건에 대한 업데이트 내용을 발표했습니다. 타겟층에 아동이 포함되는 앱의 경우 아동 또는 연령을 알 수 없는 사용자의 일부 식별자(광고 ID 포함)를 전송하면 안 됩니다.


2022년 초에는 아동 대상 서비스로 취급하기 위해 tagForChildDirected (TFCD) 또는 tagForunderAgeOfConsent (TFUA)를 통해 광고 요청에 태그가 지정된 경우 광고 ID가 전송되지 않게 하는 새로운 GMA SDK 버전을 출시할 예정입니다.


앱의 타겟층에 아동이 포함된 경우 GMA SDK를 2022년 초에 출시되는 최신 버전으로 업데이트하시기 바랍니다. 


광고 ID 권한이 내 앱에 병합되지 않게 하여 앱 전체의 광고 ID를 사용 중지할 수도 있습니다.


앱 사용자의 연령대가 다양하면 아동과 성인을 구분하기 위해 중립적인 연령 심사를 해야 할 수도 있습니다. Google에서는 2022년 초에 AdMob의 개인 정보 보호 및 메시지 도구의 일부로 중립적인 연령 심사 메시지를 출시할 예정입니다.


 


--- 복사한 글 끝 ---


 


 


아직은 잘 이해가 되지 않았다.  추가로 뭔가를 알게 되면 수정해 보도록 하겠다.


 


 









오늘의 이야기


#billcorea #운동동아리관리앱
🏸 Schneedle, un'app indispensabile per i club di badminton!
👉 Match Play: registra punteggi e trova avversari 🎉
Perfetto ovunque, da solo, con gli amici o in un club! 🤝
Se ti piace il badminton, provalo sicuramente

Vai all'app 👉 https://play.google.com/store/apps/details?id=com.billcorea.matchplay




오늘의 이야기

https://www.instagram.com/p/CYpyr7gP1bD24_ClRSykWTdwAlj8mrnYZuASig0/


반려 거북이의 일상을 공유하는 별그램을 하나 운영하고 있다... 그런데, 어느날 부터 사진 업로드가 되지 않는 것이다. 


이전에는 instabot 이라는 라이브러리를 사용하고 있었는데, 이것이 자꾸 429 error 을 받으면서 로그인이 되지 않고 넘어가지 않는 것이다. 


 


그래서 찾아보니, 이런 클라이언트가 있었다. instabrapi ... 게시자의 글로는 비공식 이라는 표현이 있기는 하지만, 


코드도 간결하게 사용이 되고 사진 업로드 역시 어렵지 않게 구현이 되었다.


 


https://github.com/adw0rd/instagrapi



 


GitHub - adw0rd/instagrapi: 🔥 The fastest and powerful Python library for Instagram Private API 2022


🔥 The fastest and powerful Python library for Instagram Private API 2022 - GitHub - adw0rd/instagrapi: 🔥 The fastest and powerful Python library for Instagram Private API 2022


github.com




 


https://adw0rd.github.io/instagrapi/usage-guide/media.html



 


Media


🔥 The fastest and powerful Python library for Instagram Private API 2022


adw0rd.github.io




사진 올리는 예제가 나와 있는 페이지는 위에 링크를 참조하면 될 것 같다. 


그럼 이제 나의 코드를 볼까 ?


 


from instagrapi import Client
import picamera # 라즈베리파이 카메라 모듈

cl = Client()
cl.login("na.....r", "wl.....#") # 별그램 아이디, 패스워드

camera = picamera.PiCamera()
camera.capture('file.jpg')

media = cl.photo_upload(
"file.jpg",
"#거북이 #python #bot",
extra_data={
"custom_accessibility_caption": "취미생활",
"like_and_view_counts_disabled": 0,
"disable_comments": 0,
}
)

print("job end")

위 코드 예시 처럼  계정아이디와 비밀번호만 넣으면 간단하다. 


photo_upload 함수는 위의 예시 처럼, 파일 이름 과, 게시용 글자만 넣어 주면 되고


그 아래 선택사항이 있는 데, 그 옵션은 0 이면 선택 안함 이고, 1이면 선택함이 된다.  그래서 1로 하는 경우는 댓글 달기 기능과 좋아요 카운트가 보이지 않게 된다. 그래서 난 0으로 선택해서 upload 을 하였다. 


 


별그램에서는 아래 그림 처럼 잘 올라간다...


 


별그림 페이지



 


매일 매일 올리는 건 어떻게 ?  raspberry pi 라는 게 있어서 난 그것을 이용하고 있다. 저렴한 서버(?)을 하나 가지고 있다고 해야 하나 ? ㅋㅋㅋ


 





오늘의 이야기


https://developer.android.com/guide/playcore/in-app-review







Google Play In-App Review API  |  Android 개발자  |  Android Developers


Google Play In-App Review API Google Play In-App Review API를 사용하면 앱 또는 게임을 종료하는 불편함 없이 Play 스토어 평점 및 리뷰를 제출하도록 요청하는 메시지를 사용자에게 표시할 수 있습니다. 일반


developer.android.com




오늘은 개발자 가이드를 참고해서 내가 만든 앱에 리뷰를 유도하는 동작을 만들어 보겠다. 가이드에 따르면 1개월 이내 반복적인 시도를 하는 경우 제한이 될 수 도 있다고 하니, 사용자에게 너무 많은 횟수의 리뷰요청은 하지 않는 것이 좋을 것 같다.


https://developer.android.com/guide/playcore/in-app-review/kotlin-java#java







인앱 리뷰 통합(Kotlin 또는 자바)  |  Android 개발자  |  Android Developers


인앱 리뷰 통합(Kotlin 또는 자바) 이 가이드에서는 Kotlin 또는 자바를 사용하여 앱에 인앱 리뷰를 통합하는 방법을 설명합니다. 네이티브 코드 또는 Unity를 사용한다면 별도의 통합 가이드를 참고


developer.android.com





java 코딩 가이드는 위 링크를 참고해서 만들어 보았다. 먼저 build gradle 부터 추가 했다.


dependencies { ..... // 리뷰를 달아주세요... implementation "com.google.android.play:core:1.10.3" ..... }

kotlin 의 경우는 추가로 kotlin 에 관련된 implement 을 해야 하나, java 의 경우는 저것도 해도 된다.

구현된 코드는 아래와 같이 구현 하였다. 가이드에서 설명은 토막 토막 이라 연결하기가 어떨지 모르겠지만, 구현된 코드를 보고 있으면 그렇게 어렵지는 않겠다고 생각이 된다.


 @Override protected void onStart() { super.onStart(); if (!sp.getBoolean("REVIEW", false)) { try { doCheckReview(); } catch (ParseException e) { e.printStackTrace(); } } } private void doCheckReview() throws ParseException { Calendar now = Calendar.getInstance(); SimpleDateFormat sdf = new SimpleDateFormat("yyyyMMdd"); if ((now.getTimeInMillis() - sdf.parse(sp.getString("ReviewDate", "20220112")).getTime()) / (1000 * 60 * 60 * 24) > 30) { ReviewManager manager = ReviewManagerFactory.create(this); com.google.android.play.core.tasks.Task<ReviewInfo> request = manager.requestReviewFlow(); request.addOnCompleteListener(task -> { if (task.isSuccessful()) { // We can get the ReviewInfo object ReviewInfo reviewInfo = task.getResult(); doReviewMake(manager, reviewInfo); Log.e(TAG, "describeContents=" + reviewInfo.describeContents()); } else { // There was some problem, log or handle the error code. @ReviewErrorCode int reviewErrorCode = ((RuntimeExecutionException) task.getException()).getErrorCode(); Log.e(TAG, "reviewErrorCode=" + reviewErrorCode) ; } }); } } private void doReviewMake(ReviewManager manager, ReviewInfo reviewInfo) { com.google.android.play.core.tasks.Task<Void> flow = manager.launchReviewFlow(this, reviewInfo); flow.addOnCompleteListener(task -> { // The flow has finished. The API does not indicate whether the user // reviewed or not, or even whether the review dialog was shown. Thus, no // matter the result, we continue our app flow. Log.e(TAG, "make Review ... "); sp = PreferenceManager.getDefaultSharedPreferences(this); Calendar now = Calendar.getInstance(); SimpleDateFormat sdf = new SimpleDateFormat("yyyyMMdd"); editor = sp.edit(); editor.putString("ReviewDate", sdf.format(now.getTimeInMillis())); editor.commit(); Log.e(TAG, "date=" + sdf.format(now.getTimeInMillis())); }); }

위 코드에서는 sp (SharedPreferencees) 을 이용해서 REVIEW 여부와 ReviewDate 을 기록해 두었다가 비교해서 너무 자주 Review 요청을 하지 않도록 관리하고자 하는 코드 구현이 들어가 있다. 다른 부분들은 가이드에 나와 있는 그대로(?) 이게 구현 되었다.

리뷰를 요청



이 처럼 앱이 구동할 때 체크해서 리뷰를 작성하도록 요청하는 팝업을 구현 하였다. 가이드에 나와 있는 것 처럼 저 팝업을 가공하거나, unique 한 것을 편집 하여 만들지 않도록 주의 하여야 한다.

이것으로 오늘 이야기는 끝~





오늘의 이야기

소셜 로그인 firebase에서 지원하고 있는 소셜 로그인(?)은 Google, Facebook, Apple, Microsoft, Twitter 등 대부분 외국계(?)입니다. firebase 의 소셜 로그인 지원 우리나라에서 대다수가 사용하는 nave...