
이런 이미지는 어떤 생각에서 만들어 내는 것일까 ?
식물인지 동물인지 야간은 애매한 이미지 이다. 늘 새로운 생각이 드는 건 맞는 데... 이걸 보면서 오늘은 또 어떤 생각이 들어야 하는 건지...
또 다른 시간이 오길 기다리며...

이런 이미지는 어떤 생각에서 만들어 내는 것일까 ?
식물인지 동물인지 야간은 애매한 이미지 이다. 늘 새로운 생각이 드는 건 맞는 데... 이걸 보면서 오늘은 또 어떤 생각이 들어야 하는 건지...
또 다른 시간이 오길 기다리며...
앱을 만들다 보니 이런 일도 생긴다. 주 내용은 다름이 아니라 앱의 접근성에 관한 부분이다.
구글은 사용자가 편리하게 앱을 사용하게 될 수 있도록 하기 위해서 이기는 하겠지만 , 개발자 입장에서는 아무래도 번거로운 일이 아닐까 싶다.
앱을 만들어 console 에 올리고 내부 테스트 버전을 적용하였더니, 한 두시간후에 메일와 와서 열어 보았더니, 아래 와 같은 출시 사전 보고서를 볼 수 있었다.

접근성과 관련 해서는 앱의 버튼 메뉴등을 만들어 달았는데, 그것들이 시각장애를 가지고 있는 친구들이 사용하기에 불편함이 없도록 하려면 그것들을 청각적인 요소로 들려 주어야 하는데, 그것을 알려 주어야 하는 것이다.
방법은 각 layout object 에 contentDesciption 을 설정해 주는 것이다. 앱의 구동되고 있는 동안에도 동적으로 그 설정을 지정할 수 있는 데,
그런때에는 setContentDescription 을 이용해서 설정을 해 주는 것이다.
그럼... 오늘은 여기 까지...

https://developer.android.com/guide/components/broadcasts?hl=ko
브로드캐스트 개요 | Android 개발자 | Android Developers
브로드캐스트 개요 Android 앱은 Android 시스템 및 기타 Android 앱에서 게시-구독 디자인 패턴과 유사한 브로드캐스트 메시지를 받거나 보낼 수 있습니다. 관심 있는 이벤트가 발생할 때 이러한 브로
developer.android.com
앱을 만들다 보니 구글이 싫어하는 암시적 intent 설정에 대한 이슈가 있었다. 그래서 찾아보다 알게된 것...
특히 앱에서 broadcasting 을 하게 되면, 구글은 모든앱이 받는 이슈에 대해서 싫어 한다.
그래서 이제 부터는 모든 것을 명시적으로 선언해 주어야 만 하는 난관(?)에 봉착하게 된다. 앞으로는 개발하는 게 더 힘들어 질 것 같다. 흑~
Intent intent = new Intent();
intent.setAction("com.example.broadcast.MY_NOTIFICATION");
intent.putExtra("data","Notice me senpai!");
intent.setPackage(getApplicationPackageName());
sendBroadcast(intent);앞으로는 이렇게 그냥 나의 앱 packageName 을 명시적으로 선언해 주는 습관을 드려야 할 것 같다.
playstore 에 앱을 게시하고자 올리면 테스트 단계를 거치는 데, 7~8 시간 대기가 필요하게 되므로... 시간 절약을 위해서.
오늘은 내 앱의 화면에 올라온 내용중에 webView 에 들어 있는 것을 이미지 파일로 만들고 pdf 파일에 담아서 공유하는 기능을 구현해 보고자 한다.
먼저할 것은 인터넷 접속을 위한 권한 부여
<uses-permission android:name="android.permission.INTERNET"/>굳이 사용자에게 허가를 받지 않아도 된다. 그 다음에 layout 을 구성해 보았다.

보는 것 처럼 화면에는 버튼 한개와 webView 만 한개 만들어 두었다.
layout의 코드는 다음과 같이.
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/layout"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".MainActivity">
<Button
android:id="@+id/pdfShare"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="40dp"
android:layout_marginTop="16dp"
android:text="Button"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<WebView
android:id="@+id/webView"
android:layout_width="0dp"
android:layout_height="645dp"
android:layout_marginTop="4dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0.0"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/pdfShare" />
</androidx.constraintlayout.widget.ConstraintLayout>
그 다음은 이미지를 임시로 저장하고 그걸 pdf 로 전환하는 방법을 구현할 예정이기 때문에 임시저장소를 선택할 수 있도록 file_provider 의 값을 등록해 준다. res/xml 폴더에 file_provider.xml 로 아래 내용을 저장해 주었다.
cache 을 사용하는 것은 파일 저장을 하기 위해서 사용자에게 권한을 허가 받지 않아도 되기 떄문이다.
<?xml version="1.0" encoding="utf-8"?>
<paths xmlns:android="http://schemas.android.com/apk/res/android">
<cache-path name="cache" path="."/>
</paths>
다음은 activity을 구현해 보아야겠다.
먼저 webView 에 나의 블로그를 load할 수 있도록 하고, load가 다 되었다면 그 때 이미지 파일에 임시 저장을 한 다음,
버튼을 클릭하면 그 이미지 파일을 열어서 공유하는 intent 을 호출 하도록 구현할 예정이다.
import androidx.appcompat.app.AppCompatActivity;
import androidx.constraintlayout.widget.ConstraintLayout;
import androidx.core.app.ActivityCompat;
import androidx.core.app.ShareCompat;
import androidx.core.content.FileProvider;
import android.Manifest;
import android.annotation.SuppressLint;
import android.content.Intent;
import android.content.pm.PackageManager;
import android.content.pm.ResolveInfo;
import android.graphics.Bitmap;
import android.graphics.BitmapFactory;
import android.graphics.Canvas;
import android.graphics.Paint;
import android.graphics.Typeface;
import android.graphics.drawable.Drawable;
import android.graphics.pdf.PdfDocument;
import android.net.Uri;
import android.os.AsyncTask;
import android.os.Bundle;
import android.os.Environment;
import android.util.Log;
import android.view.View;
import android.webkit.WebView;
import android.webkit.WebViewClient;
import android.widget.Toast;
import com.billcoreatech.pdftest.databinding.ActivityMainBinding;
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.OutputStream;
import java.util.List;
public class MainActivity extends AppCompatActivity {
private static final String TAG = "MainActivity";
Bitmap bitmap, scalebmp ;
int pageWidth = 1200 ;
ActivityMainBinding bind ;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
bind = ActivityMainBinding.inflate(getLayoutInflater());
setContentView(bind.getRoot());
bind.webView.loadUrl("https://billcorea.tistory.com");
bind.webView.setWebViewClient(new WebViewClient() {
@Override
public void onPageFinished(WebView view, String url) {
super.onPageFinished(view, url);
new Background().execute();
}
});
bind.pdfShare.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View view) {
if (createPDF ()) {
File pdfFile = new File(getCacheDir(), "/pizza.pdf");
Uri contentUri = FileProvider.getUriForFile(getApplicationContext(), getApplicationContext().getPackageName()+".fileProvider", pdfFile);
Intent shareIntent = new Intent(Intent.ACTION_SEND);
shareIntent.setType("application/pdf");
shareIntent.putExtra(Intent.EXTRA_STREAM, contentUri);
shareIntent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
List<ResolveInfo> resInfoList = getPackageManager().queryIntentActivities(shareIntent, PackageManager.MATCH_DEFAULT_ONLY);
for (ResolveInfo resolveInfo : resInfoList) {
String packageName = resolveInfo.activityInfo.packageName;
grantUriPermission(packageName, contentUri,
Intent.FLAG_GRANT_READ_URI_PERMISSION);
}
startActivity(Intent.createChooser(shareIntent, "PDF 공유"));
};
}
});
}
private boolean createPDF() {
File file = new File(getCacheDir(), "/aaaa.png");
if (!file.isFile()) {
Toast.makeText(getApplicationContext(), "파일 생성이 되지 않았습니다.", Toast.LENGTH_SHORT).show();
return false ;
}
bitmap = BitmapFactory.decodeFile(getCacheDir().toString() + "/aaaa.png");
PdfDocument pdfDocument = new PdfDocument();
Paint paint = new Paint();
PdfDocument.PageInfo pageInfo = new PdfDocument.PageInfo.Builder(bitmap.getWidth(), bitmap.getHeight(), 1).create();
PdfDocument.Page page = pdfDocument.startPage(pageInfo);
Canvas canvas = page.getCanvas();
canvas.drawBitmap(bitmap, 0, 0, paint);
pdfDocument.finishPage(page);
File file1 = new File(getCacheDir(), "/pizza.pdf");
try {
pdfDocument.writeTo(new FileOutputStream(file1));
} catch (IOException e) {
return false ;
}
pdfDocument.close();
return true ;
}
class Background extends AsyncTask<Void, Void, Bitmap>
{
@Override
protected Bitmap doInBackground(Void... params)
{
try
{
Thread.sleep(2000);
Bitmap bitmap = Bitmap.createBitmap(bind.webView.getWidth(), bind.webView.getHeight(), Bitmap.Config.ARGB_8888);
Canvas canvas = new Canvas(bitmap);
bind.webView.draw(canvas);
return bitmap;
}
catch (InterruptedException e){}
catch (Exception e){}
return null;
}
@SuppressLint("WrongThread")
@Override
protected void onPostExecute(Bitmap bm)
{
try {
OutputStream fOut = null;
File file = new File(getCacheDir(), "/aaaa.png");
fOut = new FileOutputStream(file);
bm.compress(Bitmap.CompressFormat.PNG, 50, fOut);
fOut.flush();
fOut.close();
bm.recycle();
Log.e(TAG, "fileName=" + getCacheDir() + "/aaaa.png");
Toast.makeText(getApplicationContext(), "공유할 파일이 생성되었습니다.", Toast.LENGTH_SHORT).show();
} catch (Exception e) {
e.printStackTrace();
Log.e(TAG, "" + e.toString());
}
}
}
}
webView 에 load는 시간이 소요 되기 떄문에 Background 로 load 된 이미지를 png 로 변환하도록 구현이 되었고,
버튼을 클릭하면 그 때 png 파일이 있는 지 체크 해서 없으면 알림을 보여주고, 있으면 해당 이미지 파일을 pdf 로 전환해서 하고, pdf 가 생성되었다면, intent 을 호출해서 공유하도록 구현하는 방식이다.
다만, API 32 기준으로 AVD에서 테스트를 해 보면서 오류 로그가 보이기는 했으나, 실행은 되었다. provider 에게 uri read 권한을 주어야 할 것 같은데, 아직 구글링을 했을 때 정확한 해소 방안은 찾을 수 없었다.
어떻게 하지 ?
알게될 날이 올까 ?
오류 해소를 위해서 다음 링크 까지는 해 보았으나... 아직 ... ㅠㅠ;;
Permission Denial while sharing file with FileProvider
I am trying to share file with FileProvider. I checked that file is shared properly with apps like Gmail, Google Drive etc. Even though following exception is thrown: 2019-08-28 11:43:03.169 12573-...
stackoverflow.com
이전에 작성했던 포스팅을 참고하여 인앱 결제를 구현했던 기억을 되살펴 보겠습니다.
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'
...
}
코드 구현은 다음과 같이 구현을 하였습니다. 코드 하나 구현해 두면 다음 프로젝트에서는 그대로 옮겨다 사용할 수 있습니다.
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 이글은 예전에 포스팅 했던 내용을 다시 수정해서 올렸습니다.

오늘은 google map platform 에서 알려온 메일에 대해서 잠시 보고 갈까 한다. 앱을 개발 하다 보니 google map 을 활용하게 되는 데, 이런 메일이 오다니...
메일의 본문은 아래와 같이 읽어 보게 되었다.
--- 메일 번역문 ---
안녕하세요, 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 장치는 다음과 같은 이점이 있습니다.
새 SDK에는 모바일에서 클라우드 기반 지도 스타일링의 일반 가용성도 포함됩니다. Android용 Maps SDK 의 지도에 대한 클라우드 기반 지도 스타일 사용 은 Dynamic Maps SKU 로 청구됩니다 . 가격 및 포함된 기능에 대한 자세한 내용은 Cloud Customization 설명서를 참조하십시오.
* 데이터 저장 공간이 2GB 이상인 Android 5.0(Lollipop) 이상의 모든 장치는 새 렌더러를 사용합니다. 다른 모든 장치는 추후 공지가 있을 때까지 레거시 렌더러를 계속 사용합니다.
앱의 문제를 예상하고 방지하려면 다음을 수행하는 것이 좋습니다 .
새 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 다람쥐 에 대해서 살펴 보고자 한다.


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


알 수 없다... 아직은...
일단 아쉬운 데로 Disable plugin 을 클릭해서 NDK 사용을 하지 않는 것으로 했더니 정리가 된 것 같기는 하다. 아직 NDK사용이 뭔지 모르는 나에게 그만...
다음은 Chipmuck 버전에서 나오는 변화된 것에 대한 내용 인데 ,
자동화된 계측 테스트에 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
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"
}
}
}
}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 에 대해서 살펴 보았다.
<알림수집기앱 : 이하 사용자앱으로 표시 >:는) 「개인정보 보호법」 제30조에 따라 정보주체의 개인정보를 보호하고 이와 관련한 고충을 신속하고 원활하게 처리할 수 있도록 하기 위하여 다음과 같이 개인정보 처리방침을 수립·공개합니다. ○ ...