2026/02/28

오늘의 이야기

바탕화면 이미지



 


이런 이미지는 어떤 생각에서 만들어 내는 것일까 ?


 


식물인지 동물인지 야간은 애매한 이미지 이다.  늘 새로운 생각이 드는 건 맞는 데... 이걸 보면서 오늘은 또 어떤 생각이 들어야 하는 건지...


 


또 다른 시간이 오길 기다리며...





오늘의 이야기

앱을 만들다 보니 이런 일도 생긴다. 주 내용은 다름이 아니라  앱의 접근성에 관한 부분이다.

구글은 사용자가 편리하게 앱을 사용하게 될 수 있도록 하기 위해서  이기는 하겠지만 ,  개발자 입장에서는  아무래도 번거로운 일이 아닐까 싶다.

앱을 만들어 console 에 올리고 내부 테스트 버전을 적용하였더니,   한 두시간후에 메일와 와서 열어 보았더니, 아래 와 같은 출시 사전 보고서를 볼 수 있었다.

사전 출시 보고서


접근성과 관련 해서는  앱의 버튼 메뉴등을 만들어 달았는데,  그것들이 시각장애를 가지고 있는 친구들이 사용하기에 불편함이 없도록 하려면 그것들을 청각적인 요소로 들려 주어야 하는데, 그것을 알려 주어야 하는 것이다.

방법은 각 layout object 에 contentDesciption 을 설정해 주는 것이다.  앱의 구동되고 있는 동안에도 동적으로 그 설정을 지정할 수 있는 데,

그런때에는 setContentDescription 을 이용해서 설정을 해 주는 것이다.

그럼... 오늘은 여기 까지...





오늘의 이야기


#스하리1000명프로젝트,
Bị lạc ở Hàn Quốc? Ngay cả khi bạn không nói được tiếng Hàn, ứng dụng này vẫn giúp bạn đi lại dễ dàng.
Chỉ cần nói ngôn ngữ của bạn—nó sẽ dịch, tìm kiếm và hiển thị kết quả bằng ngôn ngữ của bạn.
Tuyệt vời cho du khách! Hỗ trợ hơn 10 ngôn ngữ bao gồm tiếng Anh, tiếng Nhật, tiếng Trung, tiếng Việt, v.v.
Hãy thử nó ngay bây giờ!
https://play.google.com/store/apps/details?id=com.billcoreatech.opdgang1127




2026/02/27

오늘의 이야기

최종 추천 번호:
추천 [16,27,30,31,38,39]
추천 [02,04,18,29,33,40]
추천 [03,11,17,25,32,45]
추천 [07,15,24,28,34,42]
추천 [09,19,26,35,41,43]

### 다음 라운드 번호 추천 분석

#### 1. 번호 출현 빈도 분석
전체 회차(20회) 번호별 출현 빈도:
01: 5회, 02: 2회, 03: 4회, 04: 2회, 05: 3회, 06: 3회, 07: 4회, 08: 3회, 09: 3회, 10: 2회, 11: 1회, 12: 2회, 13: 1회, 15: 3회, 16: 5회, 17: 4회, 18: 1회, 19: 1회, 20: 2회, 21: 1회, 22: 1회, 23: 3회, 24: 4회, 25: 2회, 26: 3회, 27: 6회, 28: 3회, 29: 2회, 30: 4회, 31: 4회, 32: 2회, 33: 4회, 34: 2회, 35: 4회, 36: 4회, 37: 4회, 38: 5회, 39: 4회, 40: 3회, 41: 3회, 42: 2회, 43: 1회, 44: 2회, 45: 2회
가장 많이 출현한 번호 6개: [16,27,30,31,38,39]

#### 2. 홀/짝 비율 분석
회차별 홀/짝 조합 빈도:
짝 3개/홀 3개: 9회, 짝 2개/홀 4개: 5회, 짝 4개/홀 2개: 5회, 짝 1개/홀 5개: 1회
가장 빈번한 홀/짝 조합은 짝 3개, 홀 3개입니다. 마지막 라운드는 짝 3개, 홀 3개였습니다.

#### 3. 총합 및 평균 분석
역대 총합 범위: 104 - 187 (평균: 147.25)
역대 평균값 범위: 17.33 - 31.17 (평균: 24.54)
마지막 라운드(1212) 총합: 154, 평균: 25.67

#### 4. 간격 패턴 분석
번호 간격별 출현 빈도:
1: 16회, 2: 13회, 3: 16회, 4: 5회, 5: 6회, 6: 7회, 7: 6회, 8: 7회, 9: 2회, 10: 4회, 11: 2회, 12: 2회, 13: 1회, 14: 1회, 15: 3회, 16: 1회, 17: 3회, 18: 1회, 21: 1회, 22: 1회, 24: 1회, 25: 1회, 26: 1회, 30: 1회
마지막 라운드(1212) 간격: [3, 17, 6, 10, 3]
작은 간격(1-5)과 중간 간격(6-15)의 혼합이 자주 관찰됩니다.

#### 5. 패턴 일치도 및 주기 분석
과거 데이터를 통해 총합, 평균, 홀/짝 비율, 간격 패턴 등 4가지 속성을 기준으로 패턴 일치도를 분석했습니다. 일부 속성에서 동일 패턴 반복 주기가 관찰되었으나, 20회차 데이터만으로는 강력한 주기성을 단정하기 어렵습니다. 다만, 특정 패턴(예: 3짝 3홀 조합, 중간 범위의 총합)이 자주 나타나는 경향이 있습니다.

### 추천 조합 생성 근거

**추천1 [16,27,30,31,38,39]**: 전체 20회차 데이터에서 가장 높은 출현 빈도를 보인 6개의 번호를 조합했습니다.
**추천2 [02,04,18,29,33,40]**: 과거 당첨 번호의 통계적 균형(예: 짝수 3개, 홀수 3개, 중간 범위의 총합)을 고려하여 선정했습니다.
**추천3 [03,11,17,25,32,45]**: 최근 라운드의 번호 간격 패턴(작은 간격, 중간 간격, 큰 간격의 조화)을 반영하여 번호의 분포를 예측했습니다.
**추천4 [07,15,24,28,34,42]**: 출현 빈도가 높은 '핫'한 번호와 상대적으로 출현이 적은 '콜드'한 번호를 균형 있게 조합하여 선정했습니다.
**추천5 [09,19,26,35,41,43]**: 과거 특정 당첨 라운드(예: 1201회차)의 주요 특징(홀/짝 비율, 총합, 평균 등)을 모방하여 유사한 통계적 특성을 가지는 번호 조합을 생성했습니다.

### 마지막 라운드(1212회차)와 추천 조합 비교 분석

**1212회차 당첨 번호**: [05,08,25,31,41,44]
- 총합: 154, 평균: 25.67
- 홀/짝: 짝수 3개, 홀수 3개
- 간격: [3, 17, 6, 10, 3]

**추천1 조합 ([16,27,30,31,38,39]) 분석**:
- 총합: 181, 평균: 30.17 (1212회차 총합 154, 평균 25.67)
- 홀/짝: 짝수 3개, 홀수 3개 (1212회차 짝수 3개, 홀수 3개)
- 간격: [11, 3, 1, 7, 1] (1212회차 간격 [3, 17, 6, 10, 3])
- **1212회차 대비 특징**: 총합이 다소 차이 남, 홀/짝 비율이 동일함. 이 조합은 1212회차와 번호 구성이 다르며, 최근 10회차 당첨 번호와도 중복되지 않습니다.

**추천2 조합 ([02,04,18,29,33,40]) 분석**:
- 총합: 126, 평균: 21.00 (1212회차 총합 154, 평균 25.67)
- 홀/짝: 짝수 3개, 홀수 3개 (1212회차 짝수 3개, 홀수 3개)
- 간격: [2, 14, 11, 4, 7] (1212회차 간격 [3, 17, 6, 10, 3])
- **1212회차 대비 특징**: 총합이 다소 차이 남, 홀/짝 비율이 동일함. 이 조합은 1212회차와 번호 구성이 다르며, 최근 10회차 당첨 번호와도 중복되지 않습니다.

**추천3 조합 ([03,11,17,25,32,45]) 분석**:
- 총합: 133, 평균: 22.17 (1212회차 총합 154, 평균 25.67)
- 홀/짝: 짝수 2개, 홀수 4개 (1212회차 짝수 3개, 홀수 3개)
- 간격: [8, 6, 8, 7, 13] (1212회차 간격 [3, 17, 6, 10, 3])
- **1212회차 대비 특징**: 총합이 다소 차이 남, 홀/짝 비율이 다름, 일부 간격 패턴이 1212회차와 겹침. 이 조합은 1212회차와 번호 구성이 다르며, 최근 10회차 당첨 번호와도 중복되지 않습니다.

**추천4 조합 ([07,15,24,28,34,42]) 분석**:
- 총합: 150, 평균: 25.00 (1212회차 총합 154, 평균 25.67)
- 홀/짝: 짝수 3개, 홀수 3개 (1212회차 짝수 3개, 홀수 3개)
- 간격: [8, 9, 4, 6, 8] (1212회차 간격 [3, 17, 6, 10, 3])
- **1212회차 대비 특징**: 총합이 유사함, 홀/짝 비율이 동일함, 일부 간격 패턴이 1212회차와 겹침. 이 조합은 1212회차와 번호 구성이 다르며, 최근 10회차 당첨 번호와도 중복되지 않습니다.

**추천5 조합 ([09,19,26,35,41,43]) 분석**:
- 총합: 173, 평균: 28.83 (1212회차 총합 154, 평균 25.67)
- 홀/짝: 짝수 1개, 홀수 5개 (1212회차 짝수 3개, 홀수 3개)
- 간격: [10, 7, 9, 6, 2] (1212회차 간격 [3, 17, 6, 10, 3])
- **1212회차 대비 특징**: 총합이 다소 차이 남, 홀/짝 비율이 다름. 이 조합은 1212회차와 번호 구성이 다르며, 최근 10회차 당첨 번호와도 중복되지 않습니다.



사용하는 예시 영상 보기
이 앱이 궁금 하다면, 아래 링크에서 설치할 수 있습니다.
로또 645






오늘의 이야기

 


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 에시



보는 것 처럼 화면에는 버튼 한개와 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 권한을 주어야 할 것 같은데, 아직 구글링을 했을 때 정확한 해소 방안은 찾을 수 없었다.


 


어떻게 하지 ?


 


 



실행 예시


 


알게될 날이 올까 ?


 


오류 해소를 위해서 다음 링크 까지는 해 보았으나... 아직 ... ㅠㅠ;;


 


https://stackoverflow.com/questions/57689792/permission-denial-while-sharing-file-with-fileprovider/59439316#59439316



 


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




 





오늘의 이야기



#스치니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 에 대해서 살펴 보았다.


 





오늘의 이야기

<알림수집기앱 :  이하 사용자앱으로 표시 >:는) 「개인정보 보호법」 제30조에 따라 정보주체의 개인정보를 보호하고 이와 관련한 고충을 신속하고 원활하게 처리할 수 있도록 하기 위하여 다음과 같이 개인정보 처리방침을 수립·공개합니다. ○ ...