2026/03/21

오늘의 이야기

활용앱 예시


휴대폰에 있는 사진 (이미지)을 백업해 보아야겠다는 요청을 받았습니다.  하지만, 그때는 방법을 잘 모르겠더군요. 그래서 일단 찾아본다고 했는 데, 그 이후에는 더 이상의 요구를 하지 않았습니다.  그래서 이왕 찾아보았던 정보를 이용해서 앱을 하나 만들어 보기로 했습니다. 
 
https://medium.com/@sendtosaeed2/android-fetch-all-files-from-local-storage-media-store-api-e9b9 14cd71e1

Android Fetch All Files From Local Storage (Media Store API) 🥲

Hi android devs! Hope you are all fine but not fine with the media store api, cause it is good but not good for us. In this article we will…

medium.com

 
처리하는 기술적인 부분에 대해서는 이 글이 참고가 되었습니다. 안드로이드가 External Storage에 대한 access 권한을 제한하기 전에 안드로이드 11 이전에는 READ_EXTERNAL_STORAGE, WRITE_EXTERNAL_STORAGE 등으로 카메라 앱으로 촬영한 사진등의 이미지를 접근해 백업을 하거나 하는 처리를 할 수 있었습니다.
 
하지만, 안드로이드가 업데이트를 하는 과정에서 여러 가지 보안절차가 강화되었기 때문에 그걸 활용할 수 없었습니다.
 
https://developer.android.com/training/data-storage/shared/media?hl=ko 

공유 저장소의 미디어 파일에 액세스  |  Android 개발자  |  Android Developers

DataStore는 로컬 데이터를 저장하는 최신 방법을 제공합니다. SharedPreferences 대신 DataStore를 사용해야 합니다. 자세한 내용은 DataStore 가이드를 참고하세요. 공유 저장소의 미디어 파일에 액세스 컬

developer.android.com

그래서 위 참조글에서 보았던 MediaStore API을 활용해 보기로 했습니다. 권한 설정 등의 제한 사항이 발견되었기 때문에 안드로이드 13을 타깃으로 설정했습니다. 
 

gradle
defaultConfig {
applicationId "com.billcorea.......t0710"
minSdk 33
targetSdk 34
versionCode 7
versionName "0.0.7"

testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables {
useSupportLibrary true
}
}

minSdk 33으로 설정 했습니다. 아직 안드로이드 13까지 패치되지 않은 기기들이 많기는 하겠지만, 뭐 금방 다 옮겨 오지 않을까 싶습니다.  물론 지구상의 모든 안드로이드 기기를 대상으로 하겠다면 달라질 수 있습니다. 
 
다만, 한국에서 팔리는 기기등은 대부분 안드로이드 13으로 패치가 되었을 거라는 믿음(?)은 강하게 당겨 옵니다. 
 

manifest
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"
android:maxSdkVersion="32" />
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES"/>
<uses-permission android:name="android.permission.ACCESS_MEDIA_LOCATION"/>
<uses-permission android:name="com.google.android.gms.permission.AD_ID"/>

manifest에  등록한 권한 목록입니다. notify을 하기 위한 권한,  wifi 상태 확인을 위한 권한등은 기능설정을 위해서 필요한 부분이고, 
 
READ_MEDIA_IMAGES 가 필수 권한입니다.  READ_EXTERNAL_STOREAGE 은 안드로이드 API 32까지만 필요하므로 이제 지워도 될 것 같습니다.  이제 데이터를 읽어 오는 부분에 대한 코드를 작성해 보겠습니다. 
 

withContext(Dispatchers.IO) {

/**
* Android [ContentProvider]로 작업할 때 핵심 개념은
* "투영". 프로젝션은 공급자에게 요청할 열의 목록입니다.
* (정확히) SQL의 "SELECT ..." 절로 생각할 수 있습니다.
* 성명.
*
* 프로젝션을 제공하는 것은 _필수_가 아닙니다. 이 경우 `null`을 전달할 수 있습니다.
* [ContentResolver.query]에 대한 호출에서 `projection` 대신 요청하지만
* 필요한 것보다 더 많은 데이터는 성능에 영향을 미칩니다.
*
* 이 샘플에서는 몇 개의 데이터 열만 사용하므로
* 열의 하위 집합.
*/
/**
* Android [ContentProvider]로 작업할 때 핵심 개념은
* "투영". 프로젝션은 공급자에게 요청할 열의 목록입니다.
* (정확히) SQL의 "SELECT ..." 절로 생각할 수 있습니다.
* 성명.
*
* 프로젝션을 제공하는 것은 _필수_가 아닙니다. 이 경우 `null`을 전달할 수 있습니다.
* [ContentResolver.query]에 대한 호출에서 `projection` 대신 요청하지만
* 필요한 것보다 더 많은 데이터는 성능에 영향을 미칩니다.
*
* 이 샘플에서는 몇 개의 데이터 열만 사용하므로
* 열의 하위 집합.
*/
val projection = arrayOf(
MediaStore.Images.Media._ID,
MediaStore.Images.Media.DISPLAY_NAME,
MediaStore.Images.Media.DATE_ADDED,
)

/**
* `selection`은 SQL 문의 "WHERE ..." 절입니다. 가능하다
* 대신 `null`을 전달하여 이를 생략하면 모든 행이 반환됩니다.
* 이 경우 이미지를 촬영한 날짜를 기준으로 선택 항목을 사용하고 있습니다.
*
* 선택 항목에 `?`가 포함되어 있습니다. 이것은 변수를 나타냅니다.
* 다음 변수에 의해 제공됩니다.
*/
/**
* `selection`은 SQL 문의 "WHERE ..." 절입니다. 가능하다
* 대신 `null`을 전달하여 이를 생략하면 모든 행이 반환됩니다.
* 이 경우 이미지를 촬영한 날짜를 기준으로 선택 항목을 사용하고 있습니다.
*
* 선택 항목에 `?`가 포함되어 있습니다. 이것은 변수를 나타냅니다.
* 다음 변수에 의해 제공됩니다.
*/
val selection = "${MediaStore.Images.Media.DATE_ADDED} >= ?"

/**
* `selectionArgs`는 각 `?`에 대해 채워질 값 목록입니다.
* `선택`에서.
*/
/**
* `selectionArgs`는 각 `?`에 대해 채워질 값 목록입니다.
* `선택`에서.
*/
val selectionArgs = arrayOf(
dateToTimestamp(1, 1, 2000).toString()
)
selectionArgs.forEach {
Log.e("", "selectionArgs $it ${getDateTime(it)}")
}

/**
* Sort order to use. This can also be null, which will use the default sort
* order. For [MediaStore.Images], the default sort order is ascending by date taken.
*/
/**
* Sort order to use. This can also be null, which will use the default sort
* order. For [MediaStore.Images], the default sort order is ascending by date taken.
*/
val sortOrder = "${MediaStore.Images.Media.DATE_ADDED} DESC"

context.contentResolver.query(
MediaStore.Images.Media.EXTERNAL_CONTENT_URI,
projection,
selection,
selectionArgs,
sortOrder
)?.use { cursor ->

/**
* 반환된 [Cursor]에서 데이터를 가져오려면
* 관심 있는 각 열과 일치하는 인덱스를 찾습니다.
*
* 두 가지 방법이 있습니다. 첫 번째는 방법을 사용하는 것입니다
* 열 ID를 찾을 수 없는 경우 -1을 반환하는 [Cursor.getColumnIndex]. 이것
* 코드가 요청할 열을 프로그래밍 방식으로 선택하는 경우에 유용합니다.
* 그러나 객체로 파싱하기 위해 단일 방법을 사용하고 싶습니다.
*
* 우리의 경우 원하는 열을 정확히 알고 있기 때문에
* 반드시 포함되어야 한다는 점(API 1에서 모두 지원되기 때문에)
* [Cursor.getColumnIndexOrThrow]를 사용합니다. 이 방법은
* [IllegalArgumentException] 명명된 열을 찾을 수 없는 경우.
*
* 두 경우 모두 이 방법이 느리지는 않지만 결과를 캐시하려고 합니다.
* 각 행에 대해 조회할 필요가 없도록 합니다.
*/
/**
* 반환된 [Cursor]에서 데이터를 가져오려면
* 관심 있는 각 열과 일치하는 인덱스를 찾습니다.
*
* 두 가지 방법이 있습니다. 첫 번째는 방법을 사용하는 것입니다
* 열 ID를 찾을 수 없는 경우 -1을 반환하는 [Cursor.getColumnIndex]. 이것
* 코드가 요청할 열을 프로그래밍 방식으로 선택하는 경우에 유용합니다.
* 그러나 객체로 파싱하기 위해 단일 방법을 사용하고 싶습니다.
*
* 우리의 경우 원하는 열을 정확히 알고 있기 때문에
* 반드시 포함되어야 한다는 점(API 1에서 모두 지원되기 때문에)
* [Cursor.getColumnIndexOrThrow]를 사용합니다. 이 방법은
* [IllegalArgumentException] 명명된 열을 찾을 수 없는 경우.
*
* 두 경우 모두 이 방법이 느리지는 않지만 결과를 캐시하려고 합니다.
* 각 행에 대해 조회할 필요가 없도록 합니다.
*/
val idColumn = cursor.getColumnIndexOrThrow(MediaStore.Images.Media._ID)
val dateModifiedColumn =
cursor.getColumnIndexOrThrow(MediaStore.Images.Media.DATE_ADDED)
val displayNameColumn =
cursor.getColumnIndexOrThrow(MediaStore.Images.Media.DISPLAY_NAME)

while (cursor.moveToNext()) {

// Here we'll use the column indexs that we found above.
val id = cursor.getLong(idColumn)
val dateModified =
Date(TimeUnit.SECONDS.toMillis(cursor.getLong(dateModifiedColumn)))
val displayName = cursor.getString(displayNameColumn)

/**
* 이것은 가장 까다로운 부분 중 하나입니다.
*
* 이미지에 액세스하고 있기 때문에(사용하여
* [MediaStore.Images.Media.EXTERNAL_CONTENT_URI], 이를 사용하겠습니다.
* 기본 URI로 이미지의 ID를 추가합니다.
*
* 이것은 [MediaStore.Video]로 작업할 때와 완전히 동일한 방법이며
* [MediaStore.Audio]도 마찬가지입니다. `Media.EXTERNAL_CONTENT_URI`가 무엇이든
* 아이템을 얻기 위한 쿼리가 기본이고, ID는 받을 문서입니다.
* 거기에 요청하십시오.
*/

/**
* 이것은 가장 까다로운 부분 중 하나입니다.
*
* 이미지에 액세스하고 있기 때문에(사용하여
* [MediaStore.Images.Media.EXTERNAL_CONTENT_URI], 이를 사용하겠습니다.
* 기본 URI로 이미지의 ID를 추가합니다.
*
* 이것은 [MediaStore.Video]로 작업할 때와 완전히 동일한 방법이며
* [MediaStore.Audio]도 마찬가지입니다. `Media.EXTERNAL_CONTENT_URI`가 무엇이든
* 아이템을 얻기 위한 쿼리가 기본이고, ID는 받을 문서입니다.
* 거기에 요청하십시오.
*/

val contentUri = ContentUris.withAppendedId(
MediaStore.Images.Media.EXTERNAL_CONTENT_URI,
id
)

val image = MediaStoreImage(id, displayName, dateModified, contentUri)
images += image

// For debugging, we'll output the image objects we create to logcat.
// Log.e("", "Added image: $displayName $dateModified ${TimeUnit.SECONDS.toMillis(cursor.getLong(dateModifiedColumn))}")
}
}

이 코드는 android 개발자 페이지의 open 된 코드들 중에서 storeage-samples 안에 있는 MediaStore 프로젝트에서 가지고 왔음을 밝혀 둡니다.
 
https://github.com/android/storage-samples

GitHub - android/storage-samples: Multiple samples showing the best practices in storage APIs on Android.

Multiple samples showing the best practices in storage APIs on Android. - GitHub - android/storage-samples: Multiple samples showing the best practices in storage APIs on Android.

github.com

그걸 이용하면 이렇게 코드가 작성됩니다. 
 
여기에 다가 android 13에서 어떻게 적용할 것인 가 하는 부분에 대한 참조글은 처음에 인용한 글에서 참고했습니다. 
 
아무튼 이렇게 작성된 코드를 이용해서 앱 하나 만들어 playstore에 게시했습니다. ㅋ~
https://play.google.com/store/apps/details?id=com.billcorea.ftpclient0710 

사진백업 - Google Play 앱

NAS 로 내 폰의 사진을 백업해 드립니다. 자동으로 해 드릴 생각 이에요

play.google.com

 





오늘의 이야기


#스하리1000명프로젝트,
Terkadang sulit untuk berbicara dengan pekerja asing, bukan?
Saya membuat aplikasi sederhana yang membantu! Anda menulis dalam bahasa Anda, dan orang lain melihatnya dalam bahasa mereka.
Ini menerjemahkan secara otomatis berdasarkan pengaturan.
Sangat berguna untuk obrolan mudah. Lihatlah ketika Anda mendapat kesempatan!
https://play.google.com/store/apps/details?id=com.billcoreatech.multichat416




오늘의 이야기

장마속에서 맑은 하늘



장마 속에서 날 맑은 하늘...


 


어느 곳에서는 수마의 할큄에 모든 걸 잃어버리기도 했고...


 


세상을 떠나기도 했고...


 


가슴 아픈 사고가 있기도 했고...


 


오늘도 살아 있음에 감사하는 날이 되었기를...





오늘의 이야기

앱을 만들어 배포를 하다 보면 사용자가 업데이트를 놓치는 경우가 있을까 싶어서 앱을 실행할 때 자동으로 업데이트를 할 수 있도록 코드 구현을 하고 있습니다. 


 


물론 잠자는 시간에 충전을 하고 있다면 playstore 앱이 잘 알아서 업데이트를 해 주기도 합니다만, 그래도...


인앱 업데이트 deprecated



코드를 구현해 사용하다 보니 어느날 deprecated 되어 다는 이야기가 나옵니다. 안드로이드 버전이 올라가면서 나오는 현상이기는 하겠습니다만, 개발자 페이지를 찾아보겠습니다. 


 


개발자 페이지 설명



https://developer.android.com/reference/com/google/android/play/core/appupdate/AppUpdateManager



 


AppUpdateManager  |  Android Developers


Stay organized with collections Save and categorize content based on your preferences. Summary: Methods AppUpdateManager public interface AppUpdateManager com.google.android.play.core.appupdate.AppUpdateManager Manages operations that allow your app to ini


developer.android.com




 


어느 순간에 그걸 사용할 수 없다는 이야기가 나오고 그에 따르는 대응책을 알려 줍니다.  이제 그걸 따라해 보겠습니다.


 


변경후 코드 적용



 


코드가 한결 가벼워 졌습니다.  따라 하기도 수월해졌고요.


 


쉽게 구현해 보았습니다.





오늘의 이야기


#billcorea #운동동아리관리앱
🏸 Schneedle, aplikasi yang wajib dimiliki oleh klub bulu tangkis!
👉 Match Play – Rekam Skor & Temukan Lawan 🎉
Sempurna untuk di mana saja, sendirian, bersama teman, atau di klub! 🤝
Jika Anda suka bulu tangkis, cobalah

Buka aplikasi 👉 https://play.google.com/store/apps/details?id=com.billcorea.matchplay




오늘의 이야기

이 앱은 사용자의 휴대폰에 있는 사진(이미지)만 백업을 해 두고 싶을 때 사용할 수 있는 앱입니다.  이 앱 사용하기 위한 전제 조건은 2가지입니다. 
 

  1. WIFI 을 사용할 수 있는 가 ?
  2. NAS을 사용할 수 있거나, FTP 서버를 이용할 수 있는 가?

 
이 2가지 조건을 가지고 있지 않다면 그냥 이 앱을 지우고 벗어나시길 바랍니다. 
 

대표 이미지


 
이제 시작해 보겠습니다.  먼저 설정을 해 보겠습니다. 

기본화면


 
** 서버 접속 준비 하기
입력해야 하는 것은 서버 IP을 입력합니다.  앱에서는 동일한 WIFI 에서 사용할 수 있는 NAS의 FTP을 사용하는 것을 기준으로 앱을 구현하였기 때문에 IP 주소를 입력받습니다. 
 
사용자 ID와 패스워드는 NAS 의 서버 설정에 해당하는 ID 와 PASSWORD을 입력하면 됩니다. 
 
서버 경로는 FTP로 접속했을 때 경로를 찾아야 합니다.  그래서 windows 사용자의 경우에는 command 창을 열어서
 
ftp을 실행하고
open 192.168.0.7처럼 입력 하고 
user / password을 입력해 접속하여 경로를 찾아봅니다.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

FTP 접근해 보기


이 그림과 같이 서버 접속을 직접 해 보아야 하는 것은 서버 경로를 NAS의 설정에서 어떻게 하는 가에 따라서 달라지기 때문 이기도 합니다.   접속 경로가 확인되면 그 경로를 위 설정 하면에 서버 경로에 입력해 주면 됩니다. 
 


설정 버튼을 이용하여  입력한 서버 정보를 저장해 주어야 합니다.   이 작업이 되지 않은 경우 정상적인 진행을 할 수 없을 수 도 있습니다. 
 
이제 다음은 백업 주기를 설정 합니다.  구글에서는 배터리의 안정적인 운영을 위해서는 15분 이상으로 백그라운드 작업을 하도록 가이드하고 있습니다. 

그런데도 간격의 범위를 3분 ~ 50분으로 설정한 것은 필요에 따라서 설정을 달리 해야 하는 경우가 있을 수 도 있기 때문입니다. 
 
시간 설정까지를 마무리했다면 이제 백업 버튼을 이용하여 백업을 진행해 봅니다. 


백업버튼을 클릭했을 때 다른 부분은 
 
15분 미만으로 설정한 경우에는  백업 버튼을 클릭하기 전에  리스트 보기를 활성화해서 사진 목록이 조회되는 것으로 확인해야 합니다. 
 
이미지 목록이 없을 경우에는 알림이 표시됩니다. 
 
15분 이상으로 설정한 경우에는 백그라운드 반복 작업을 하기 때문에 리스트가 보이지 않아도 진행을 가능합니다. 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
이제 어떻게 동작을 하는지 예시 동영상을 한번 보겠습니다. 
 
 

실행하는 예시 동작

간혹 휴대장치의 성능 이슈 때문에 프로그래스바의 동작이 확인되지 않는 경우가 발생하기는 합니다.  프로그래스바 가 표시 되고 있는 동안은 전송건수에 숫자가 변하는 것을 확인할 수 있습니다. 
 
사라지는 사진을 백업해 보세요.
 
 
https://play.google.com/store/apps/details?id=com.billcorea.ftpclient0710 

사진백업 - Google Play 앱

NAS 로 내 폰의 사진을 백업해 드립니다. 자동으로 해 드릴 생각 이에요

play.google.com

 





오늘의 이야기

앱을 구현하는 동안 FTP 접속을 통해서 파일을 서버로 전송하는 앱을 구현해 보기로 하겠습니다. 


 


먼저 gradle 파일에 아래 항목을 추가합니다. FTP 서버를 활용하기 위해서는 필요합니다.


implementation 'commons-net:commons-net:3.9.0'

 


코드 구현에 필요한 코드는 아래와 같습니다. 이 명령줄을 이용해서  접속할 서버 정보를 이용해 파일을 보내 보는 것입니다.



import org.apache.commons.net.ftp.FTP
import org.apache.commons.net.ftp.FTPClient
import java.io.File
import java.io.FileInputStream

fun uploadFileToFtp(file: File, ftpServer: String, ftpUsername: String, ftpPassword: String, ftpDirectory: String) {
val ftpClient = FTPClient()
ftpClient.connect(ftpServer)
ftpClient.login(ftpUsername, ftpPassword)
ftpClient.enterLocalPassiveMode()
ftpClient.setFileType(FTP.BINARY_FILE_TYPE)
ftpClient.changeWorkingDirectory(ftpDirectory)

val inputStream = FileInputStream(file)
val fileName = file.name
ftpClient.storeFile(fileName, inputStream)
inputStream.close()

ftpClient.logout()
ftpClient.disconnect()
}

 


서버 접속 설정해 보기



화면에서는 서버 접속을 위한 정보를 입력하여 이 정보를 이용해서 FTP 서버에 접속해 보겠습니다.  그런데 일반적으로는 FTP 서버를 가지고 있지는 않을 것입니다.  그래서 저는 NAS을 활용해 보기로 했습니다. 


 


iptime NAS 의 FTP 서비스 정보 설정 하기



ip Time NAS 을 사용하는 경우이기 때문에 자신이 가지고 있는 것을 이용해서 설정을 해야 합니다.  



  • 포트번호는 기본값이 21입니다.  왜 21 이냐고 물어보신다면 그건 구글에서 찾아보시길 추천해 드립니다.

  • 모든 사용자를 익명으로 사용은 체크하지 않습니다. 그래야 사용자 관리에서 입력한 사용자를 선택해서 id와 password을 입력해 볼 수 있기 때문입니다. 

  • 다음은 공유폴더를 하나 선택해서 FTP에서 사용할 폴더로 지정해 주어야 합니다. 여기서 선택하는 경로를 실제 FTP client 프로그램에서 접속해 보려면 실제 접속해서 확인을 해 보아야 합니다. 


ftp 접속해 보기



windows의 경우 cmd 창을 열어서 ftp을 실행해 봅니다.  그다음과 같은 순서로 서버에 접속해 봅니다. 



  • open 192.168.0.7 ( 서버 IP는 NAS의 아이피와 동일합니다)

  • 사용자 이름 입력 

  • password 입력

  • dir을 입력해서 현재 경로를 확인합니다. 


위 그림과 같이 로그인 후 바로 확인되는 경로는 HDD1입니다. 위 설정에서는 BackupDocument였는 데 말입니다. 그래서  HDD1에 접근하고 나서 다시 확인 보면 이제야 해당 경로가 나오는 것을 알 수 있습니다.  그래서 경로 확인은 꼭 접속해 확인해 보아야 하는 이유입니다.


 


이상으로 오늘은 FTP 접속하는 방법과 kotlin 코드를 알아보았습니다.


 





오늘의 이야기


#스하리1000명프로젝트,
Lost in Korea? Même si vous ne parlez pas coréen, cette application vous aide à vous déplacer facilement.
Parlez simplement votre langue : il traduit, recherche et affiche les résultats dans votre langue.
Great for travelers! Prend en charge plus de 10 langues, dont l'anglais, le japonais, le chinois, le vietnamien et plus encore.
Try it now!
https://play.google.com/store/apps/details?id=com.billcoreatech.opdgang1127




2026/03/20

오늘의 이야기

## 로또 번호 분석 및 추천

**데이터 분석 요약:**

총 20회차 데이터를 분석하여 각 회차별 번호 패턴을 파악하고, 이를 바탕으로 다음 회차 번호를 예측합니다. 분석 항목은 다음과 같습니다.

1. **연속 번호 간 간격:** 각 회차의 번호들 간 오름차순 간격.
2. **짝수/홀수 개수:** 각 회차의 짝수 및 홀수 번호 개수.
3. **총합:** 각 회차 번호 6개의 합계 및 동일 총합 출현 간격.
4. **평균:** 각 회차 번호 6개의 평균값 및 동일 평균값 출현 간격.
5. **매칭 점수:** 각 회차의 1~4번 항목 패턴을 이전 회차들과 비교하여 산출한 매칭 비율(%) 및 동일 매칭 비율 출현 간격.
6. **번호 출현 빈도:** 전체 데이터에서 각 번호가 출현한 총 빈도수.

---

**상세 분석 결과:**

* **전체 평균 총합:** 20회차 동안 번호 6개의 평균 총합은 약 **205.83**입니다. (최소 157, 최대 264)
* **전체 평균 번호 간 간격:** 번호들 간의 평균 간격은 약 **6.84**입니다.
* **가장 빈번한 짝수/홀수 패턴:** (3 Even, 3 Odd) - 10회 출현 (50%)

**지난 회차(1215회) 분석:**

* **당첨 번호:** [13,15,19,21,44,45]
* **간격:** (2,4,2,23,1)
* **짝수/홀수:** (2 Even, 4 Odd)
* **총합:** 157
* **평균:** 26.17

---

**최종 추천 번호 및 상세 근거:**

다음 회차(1216회)를 위한 5가지 추천 조합입니다. 모든 조합은 지난 10회차 당첨 번호와 중복되지 않으며, 1부터 45 사이의 숫자로 구성됩니다.

### 1. 추천 [01,27,31,35,38,41]
* **선정 근거:** 지난 20회차 동안 가장 많이 출현한 6개의 숫자(1, 27, 38, 31, 35, 41)를 조합했습니다. 통계적으로 가장 높은 출현 빈도를 가진 숫자들로 구성되어 있습니다. 이 조합의 총합은 173으로, 전체 평균 총합(205.83)보다 낮아 최근 총합 트렌드(157)와 유사하거나 약간 높은 수준입니다. 짝수/홀수 비율은 (2 Even, 4 Odd)입니다.

### 2. 추천 [04,26,30,25,31,35]
* **선정 근거:** 지난 회차(1215회)의 짝수 2개, 홀수 4개 패턴에서 벗어나 가장 빈번하게 나타나는 짝수 3개, 홀수 3개의 균형 패턴으로 회귀할 것을 예측하여 구성했습니다. 최다 빈도수 숫자 풀에서 짝수 3개와 홀수 3개를 선택하여 구성되었습니다. 이 조합의 총합은 171, 평균은 28.5입니다.

### 3. 추천 [06,16,24,32,36,40]
* **선정 근거:** 지난 회차의 총합(157)이 전체 평균 총합(약 205.83)보다 낮았으므로, 평균 총합(205.83)에 가까운 약 200~220 범위의 숫자로 구성했습니다. 번호 간 평균 간격도 전체 평균 간격(약 6.84)과 유사하게 (4,8,8,4,4)로 배치하여 극단적인 패턴을 피했습니다. 짝수 6개, 홀수 0개의 짝수 강세 패턴입니다.

### 4. 추천 [10,18,22,28,38,42]
* **선정 근거:** 지난 회차의 번호들이 주로 중소 번호대에 집중된 경향(13,15,19,21)이 있었고 짝수 2, 홀수 4였습니다. 이를 역전하여 짝수 6개, 홀수 0개의 비율로, 번호 범위도 중후반대에 고루 분포되도록 큰 숫자들을 포함하여 구성했습니다. 총합은 158로 지난 회차와 유사하며, 평균은 26.33으로 소폭 상승했습니다.

### 5. 추천 [03,12,17,20,33,43]
* **선정 근거:** 최근 10회차 당첨 번호에서 출현 빈도가 다소 낮지만, 전체적으로는 꾸준히 출현하는 중간 빈도수의 숫자들과 최다 빈도수 숫자를 혼합했습니다. 번호 간 간격이 너무 좁거나 넓지 않도록 (9,5,3,13,10) 적절히 배분하여 다양한 범위의 숫자를 커버하도록 했습니다. 총합은 128로 낮은 편이며, 짝수 2개, 홀수 4개 조합입니다.

---

**지난 회차(1215회) 분석과 추천 조합 비교:**

| 항목 | 1215회 당첨 번호 | 추천 1번 [01,27,31,35,38,41] | 추천 2번 [04,26,30,25,31,35] | 추천 3번 [06,16,24,32,36,40] | 추천 4번 [10,18,22,28,38,42] | 추천 5번 [03,12,17,20,33,43] |
| :------------- | :-------------------- | :-------------------------- | :-------------------------- | :-------------------------- | :-------------------------- | :-------------------------- |
| **당첨 번호** | [13,15,19,21,44,45] | [01,27,31,35,38,41] | [04,25,26,30,31,35] | [06,16,24,32,36,40] | [10,18,22,28,38,42] | [03,12,17,20,33,43] |
| **총합** | 157 | 173 | 171 | 194 | 158 | 128 |
| **평균** | 26.17 | 28.83 | 28.5 | 32.33 | 26.33 | 21.33 |
| **짝수/홀수** | 2 Even, 4 Odd | 2 Even, 4 Odd | 3 Even, 3 Odd | 6 Even, 0 Odd | 6 Even, 0 Odd | 2 Even, 4 Odd |
| **간격 패턴** | (2,4,2,23,1) | (26,4,4,3,3) | (21,1,4,1,4) | (10,8,8,4,4) | (8,4,6,10,4) | (9,5,3,13,10) |
| **주요 특징** | 낮은 총합, 중소 번호대, 높은 간격 | 고빈도수, 낮은 총합, 일부 큰 간격 | 균형 잡힌 짝홀, 낮은 총합, 고빈도수 | 짝수 강세, 평균 총합, 고른 간격 | 짝수 강세, 낮은 총합, 중대 번호 | 고빈도/저빈도 혼합, 낮은 총합, 다양한 간격 |

**비교 분석 요약:**

* **총합 및 평균:** 지난 1215회차의 총합(157)과 평균(26.17)이 전체 평균보다 낮았기 때문에, 추천 조합들은 이와 유사한 낮은 총합/평균을 유지하거나(추천 1, 2, 4, 5) 전체 평균에 가까운 총합/평균(추천 3)을 가지도록 구성했습니다.
* **짝수/홀수 균형:** 1215회차는 (2 Even, 4 Odd) 패턴을 보였습니다. 추천 조합들은 이 패턴을 유지하거나(추천 1, 5), 가장 빈번한 (3 Even, 3 Odd) 패턴(추천 2), 또는 반전 패턴인 (6 Even, 0 Odd) 패턴(추천 3, 4)을 제시하여 다양한 가능성을 고려했습니다.
* **번호 간 간격:** 1215회차는 (2,4,2,23,1)과 같이 '23'이라는 매우 큰 간격이 특징적이었습니다. 추천 조합들은 이처럼 극단적인 간격보다는 전체 평균 간격(약 6.84)에 가깝거나, 특정 구간에 집중되는 경향을 보이도록 구성했습니다. 예를 들어, 추천 3번은 비교적 고른 간격(10,8,8,4,4)을 가지며, 추천 5번은 큰 간격(13,10)을 일부 포함하지만 1215회차의 23만큼 극단적이지는 않습니다.
* **번호대 분포:** 1215회차는 [13,15,19,21]과 같은 중소 번호와 [44,45]의 큰 번호가 혼합된 형태였습니다. 추천 조합들은 최다 빈도수 숫자를 기반으로 하되, 중소 번호, 중대 번호, 고른 분포 등 다양한 번호대 조합을 시도하여 예측의 폭을 넓혔습니다.



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






오늘의 이야기

오늘은 앱의 하단  탐색바를 만들다 고민이 생긴 걸 해소하는 이야기를 적어 봅니다.  


https://medium.com/@banmarkovic/jetpack-compose-clear-back-stack-popbackstack-inclusive-explain ed-14ee73a29df5



 


Jetpack Compose clear back stack, popBackStack inclusive explained


Understand the popUpTo and inclusive parameter for navigation-compose lib, and learn how to clear back stack.


medium.com




이 이야기의 출처는 위에 링크를 참조해 주세요.   이야기의 중요 논점은 내비게이션 바를 이용해서 화면 간의 이동을 하게 되는 경우 어떻게 뒤로 가기를 정리할 것인가 하는 문제와 만나게 되면서 시작됩니다. 


 


bottom bar navigation 은 stack에 메뉴을 하나씩 넣어 주면서 화면을 이용 합니다. stack 이라는 것이 FILO 라는 규칙을 따라가기 때문에 차곡 차곡 쌓아 두었다가 하나씩 빠져 나가는 것으로 처리를 하게 됩니다. 


 


popbackStack 의 처리 예시



위 그림과 같이  stack 에 Login, Home, Profile이라는 화면을 하나씩 선택해 들어갔을 때 어떻게 해서 정리를 해 두면 좋을까 하는 부분에 대한 고민이 생겨 납니다. 


 


사용자들은 앱을 사용하다가 뒤로 가기 버튼을 클릭하는 것으로 앱을 종료하려고 합니다.  (물론 그냥 닫아 버리기도 하기는 합니다.) 그랬을 때 스택에 들어 있는 것들이 많았다면 화면을 하나하나씩 빼내어 뒤로 가기를 실행하다가 stack에서 다 빠지게 되면 그때서야 앱을 종료하는 onBackPress Call Back을 타게 됩니다. 


 


그래서 저는 저 이야기를 읽고 나서 앱의 bottom bar navigation을 구현하면서 다음과 같이 탐색하는 방법을 변경해 보았습니다. 


 



dropletButtons.forEachIndexed { index, it ->
DropletButton(
modifier = Modifier.fillMaxSize(),
isSelected = dataViewModel.selectedItem.value == index,
onClick = {
Log.e("", "index = $index")
dataViewModel.selectedItem.value = index
naviController.navigate(it.direction) {
popUpTo(naviController.graph.id) {
inclusive = true
}
}
},
icon = it.icon,
size = 26.dp,
contentDescription = stringResource(id = it.description),
dropletColor = Color.White,
animationSpec = tween(durationMillis = Duration, easing = LinearEasing)
)
}


navigate(다음화면) { popUpTo(화면 ID) { inclusive = true } } 이렇게 호출하는 것으로 해서 stack에 들어가는 것들을 지우면서 화면 이동을 시도 했습니다.   결국에는 stack 에 저장된 것이 마지막 하나뿐인 상태가 되는 것입니다. 


 


그랬더니, 뒤로 가기 버튼을 클릭하면 바로 onBack Press Call Back으로 들어옵니다.  이제 한 번에 뒤로 가기 버튼을 이용한 앱 종료 액션을 처리해 볼 수 있었습니다. 


 


오늘도 즐~ 코딩...


 


이제 얼마 남지 않은 장맛비를 잘 달래 보내야 할 때가 된 것 같습니다.





오늘의 이야기

오늘만에 제대로 된 개발 이야기를 적어 보겠습니다.  한동안 외주 프로젝트에 참여를 하고 있어서 마음에 여유가 없었던 탓이기도 하고, 게으른 탓이기도 하겠지만, 개발이야기는 적어 보지 못했습니다.   그동안 눈팅(?)하던 jetpack compose 이야기에서 애니메이션이 가미된 navigation bar을 만들어 보겠습니다. 


 


https://medium.com/proandroiddev/jetpack-compose-tutorial-animated-navigation-bar-354411c679c8



 


Jetpack Compose Tutorial: Animated Navigation Bar


How to implement a navigation bar with smooth custom animations


proandroiddev.com




 원작자의 이야기는 여기에서 보실 수 있습니다.  영문 버전으로 이해를 해야 하기에 나름의 고충(?)도 있을 수 있으나, 이런 정도는 번역기가 잘 번역을 해 줍니다. 


 


이글의 이야기들을 읽다 보면 마치 무슨 수학 함수를 풀어내야만 하는 것 처럼 보이기도 합니다.  아무튼... 다이내믹한 뭔가를 얻어 내는 데, 무한한 노력이 필요해 보이기는 합니다. 


 


//AnimatedNavigationBar
implementation("com.exyte:animated-navigation-bar:1.0.0")

애니메이션 네비게이트를 활용하기 위해서 gradle 파일에 추가를 했습니다. 


 


이번에는 메뉴에 표시할 항목등을 만들어 보도록 하겠습니다.  여러개를 만들어 낼 수 도 있지만, 예시에서는 메뉴 항목이 5개인 것을 구현해 보겠습니다.


 


import androidx.annotation.DrawableRes
import androidx.annotation.StringRes
import androidx.compose.animation.core.tween
import androidx.compose.runtime.Stable
import com.billcorea.bluetooth627.R

@Stable
data class Item(
@DrawableRes val icon: Int,
var isSelected: Boolean,
@StringRes val description: Int,
val animationType: ColorButtonAnimation = BellColorButton(
tween(500),
background = ButtonBackground(R.drawable.plus)
),
)

val dropletButtons = listOf(
Item(
icon = R.drawable.home,
isSelected = false,
description = R.string.Home
),
Item(
icon = R.drawable.bell,
isSelected = false,
description = R.string.Bell
),
Item(
icon = R.drawable.message_buble,
isSelected = false,
description = R.string.Message
),
Item(
icon = R.drawable.heart,
isSelected = false,
description = R.string.Heart
),
Item(
icon = R.drawable.outline_menu_24,
isSelected = false,
description = R.string.Menu
),

)

이렇게 메뉴에 들어갈 항목을 만들어 보았습니다. 다만, 여기서 알 수 없는 것은 BellColorButton 이 위에서 implemention에서 가져오지 못한 다는 것입니다.  알 수 없는 일이기도 합니다.  아무튼 그것에 관련된 코드 부분은 github에 있는 project 파일에서 가져왔음을 밝혀 둡니다. 


 


import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.FiniteAnimationSpec
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.material3.Icon
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource
import com.exyte.animatednavbar.utils.rotationWithTopCenterAnchor
import kotlin.math.PI
import kotlin.math.sin

class BellColorButton(
override val animationSpec: FiniteAnimationSpec<Float> = tween(),
override val background: ButtonBackground,
private val maxDegrees: Float = 30f,
) : ColorButtonAnimation(animationSpec, background) {

@Composable
override fun AnimatingIcon(
modifier: Modifier,
isSelected: Boolean,
isFromLeft: Boolean,
icon: Int,
) {
val rotationFraction = animateFloatAsState(
targetValue = if (isSelected) 1f else 0f,
animationSpec = animationSpec,
label = "rotationFractionAnimation"
)

val color = animateColorAsState(
targetValue = if (isSelected) Color.Black else Color.LightGray,
label = "colorAnimation"
)

Icon(
modifier = modifier
.rotationWithTopCenterAnchor(
if (isSelected) degreesRotationInterpolation(
maxDegrees,
rotationFraction.value
) else 0f
),
painter = painterResource(id = icon),
contentDescription = null,
tint = color.value
)
}

private fun degreesRotationInterpolation(maxDegrees: Float, fraction: Float) =
sin(fraction * 2 * PI).toFloat() * maxDegrees
}

import androidx.annotation.DrawableRes
import androidx.compose.animation.core.FiniteAnimationSpec
import androidx.compose.animation.core.tween
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Stable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.DpOffset

data class ButtonBackground(
@DrawableRes val icon: Int,
val offset: DpOffset = DpOffset.Zero
)

@Stable
abstract class ColorButtonAnimation(
open val animationSpec: FiniteAnimationSpec<Float> = tween(10000),
open val background: ButtonBackground,
) {
@Composable
abstract fun AnimatingIcon(
modifier: Modifier,
isSelected: Boolean,
isFromLeft: Boolean,
icon: Int,
)
}

이렇게 이번  project 에서 가져다 사용할 것만 추려서 가져왔습니다.  하단바 메뉴를 구현해 보겠습니다.


 


import android.util.Log
import android.view.animation.OvershootInterpolator
import androidx.annotation.StringRes
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.LinearOutSlowInEasing
import androidx.compose.animation.core.tween
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.navigation.NavHostController
import com.billcorea.bluetooth627.DoubleDuration
import com.billcorea.bluetooth627.Duration
import com.billcorea.bluetooth627.R
import com.billcorea.bluetooth627.composes.destinations.AuthorSettingScreenDestination
import com.billcorea.bluetooth627.composes.destinations.DirectionDestination
import com.billcorea.bluetooth627.composes.destinations.MainScreenDestination
import com.billcorea.bluetooth627.ui.theme.Purple
import com.billcorea.bluetooth627.ui.theme.SoftBlue
import com.billcorea.bluetooth627.ui.theme.SoftBlue40
import com.exyte.animatednavbar.AnimatedNavigationBar
import com.exyte.animatednavbar.animation.balltrajectory.Parabolic
import com.exyte.animatednavbar.animation.indendshape.Height
import com.exyte.animatednavbar.animation.indendshape.shapeCornerRadius
import com.exyte.animatednavbar.items.dropletbutton.DropletButton
import com.ramcosta.composedestinations.navigation.navigate

@Composable
fun BottomBar( naviController : NavHostController) {

var selectedItem by remember { mutableStateOf(0) }
AnimatedNavigationBar(
modifier = Modifier
.padding(horizontal = 4.dp, vertical = 4.dp)
.height(90.dp),
selectedIndex = selectedItem,
barColor = SoftBlue,
ballColor = SoftBlue,
cornerRadius = shapeCornerRadius(25.dp),
ballAnimation = Parabolic(tween(Duration, easing = LinearOutSlowInEasing)),
indentAnimation = Height(
indentWidth = 56.dp,
indentHeight = 15.dp,
animationSpec = tween(
DoubleDuration,
easing = { OvershootInterpolator().getInterpolation(it) })
)
) {
dropletButtons.forEachIndexed { index, it ->
DropletButton(
modifier = Modifier.fillMaxSize(),
isSelected = selectedItem == index,
onClick = {
Log.e("", "index = $index")
selectedItem = index
when (selectedItem) {
0 -> {
naviController.navigate(BottomMenuItems.Main.direction)
}
4 -> {
naviController.navigate(BottomMenuItems.Setting.direction)
}
else ->{

}
}
},
icon = it.icon,
contentDescription = stringResource(id = it.description),
dropletColor = Color.White,
animationSpec = tween(durationMillis = Duration, easing = LinearEasing)
)
}
}
}

enum class BottomMenuItems(
val direction : DirectionDestination,
@StringRes val label : Int,
) {
Main(MainScreenDestination, R.string.Main),
Setting(AuthorSettingScreenDestination, R.string.Setting),
}

 


아직은 테스트를 위한 것이라 메뉴가 2개만 들어 있는 것 같은 느낌인 데, 실행해 보면 다음과 같이 구현이 되는 것을 알 수 있습니다.


 


 



애니메이션 네비게이트


이렇게 오늘도 따라 하기 코딩 하나를 해 보았습니다.  조금 아쉬운 부분은 네비게이션 구현을 함에 있어서 메뉴에 label 이 나오지 않는다는 것이 하단바 메뉴로 사용하기에는 부족한 부분이 아닐까 하는 생각을 해 보았습니다.   아무튼 오늘도 끝.


기본 이미지



 





오늘의 이야기

이전 글에서 정리할 것처럼 java에서 kotlin으로 이전을 했습니다.  그러고 나서 보기 시작했는 데,  DefaultSharedPrefernces의 사용할 수 없는 환경으로 변경이 된 것을 알게 되었습니다.    이전 prefs = Prefere...