2026/03/12

오늘의 이야기

이전 포스팅에서 SMS 또는 LMS을 읽어오는 부분에 대해서는 기술한 바 있습니다. 이여서 하는 이야기는 MMS을 확인해 보는 방법입니다. 이전 포스팅으로 MMS가 읽어져 오는 것으로 이해를 하고 있었습니다만... 실제로는 그것이 아니라는 특히 국내에서 출시된 개선된(?) 안드로이드를 사용하는 경우 그런 기능으로는 정보를 다 가지고 오지 못하는 부분이 있다는 것을 알게 되었습니다.


 


MMS 수신 이벤트 확인


국내향 안드로이드의 경우는 SMS와 달리 MMS의 경우는 수신을 했다고 해도 Event 를 감지할 수 없었습니다. 그래서 대안으로 처리한 것은 알림 수신을 이용하는 것입니다. (알림 수신에 대해서는 이전 포스팅을 참고해 주세요)


 


import android.annotation.SuppressLint
import android.app.Notification
import android.content.ContentResolver
import android.content.Context
import android.database.Cursor
import android.net.Uri
import android.os.Build
import android.service.notification.NotificationListenerService
import android.service.notification.StatusBarNotification
import android.util.Log
import androidx.annotation.RequiresApi
.....
import java.io.BufferedReader
import java.io.InputStreamReader
import java.text.SimpleDateFormat
import java.util.*


class MyNotificationListener : NotificationListenerService() {

val TAG = "MyNotificationListener"
val MMS_URI: Uri = Uri.parse("content://mms-sms/conversations/")
val mmsRcvNumbers = mutableListOf<MmsBean>()

override fun onNotificationRemoved(sbn: StatusBarNotification) {
super.onNotificationRemoved(sbn)
Log.e(
TAG, "onNotificationRemoved ~ " +
" packageName: " + sbn.packageName +
" id: " + sbn.id
)
}

/**
* MMS 발송자 전화 번호 알아 내기
*/
@SuppressLint("Range")
private fun getAddressNumber(id: Int): String? {
val selectionAdd = "msg_id=${id}"
val uriStr = "content://mms/${id}/addr"
val uriAddress = Uri.parse(uriStr)
val cAdd = contentResolver.query(
uriAddress, null,
selectionAdd, null, null
)
var name: String? = ""
try {
if (cAdd!!.moveToFirst()) {
do {
val number = cAdd.getString(cAdd.getColumnIndex("address"))
if (number != null) {
try {
number.replace("-", "").toLong()
name = number
} catch (nfe: NumberFormatException) {
if (name == null) {
name = number
}
}
}
} while (cAdd.moveToNext())
}
if (cAdd != null) {
cAdd.close()
}
} catch (e : Exception) {

}
return name
}

/**
* MMS 의 본문 내용 읽어 오기
*/
private fun getMMsPart(mId: String?): String {
var rValue = ""
var selectionPart: String = "mid=" + mId;
var uriPart : Uri = Uri.parse("content://mms/part");
var cur : Cursor? = contentResolver.query(uriPart, null, selectionPart, null, null);
if (cur != null) {
if (cur.moveToFirst()) {
do {
rValue = getMmsBody(cur)
} while (cur.moveToNext())
}
}
return rValue
}

/**
* MMS Body read
*/
private fun getMmsBody(partCursor: Cursor): String {
val partId = partCursor.getString(partCursor.getColumnIndexOrThrow("_id"))
val data = partCursor.getString(partCursor.getColumnIndexOrThrow("_data"))
return if (data != null) {
getMessageText(contentResolver, partId)
} else {
partCursor.getString(partCursor.getColumnIndexOrThrow("text"))
}

}

/**
* MMS body get Text
*/
private fun getMessageText(contentResolver: ContentResolver, id: String): String {
val partUri = Uri.parse("content://mms/part/$id")
val stringBuilder = StringBuilder()
val inputStream = contentResolver.openInputStream(partUri)

if (inputStream != null) {
val inputStreamReader = InputStreamReader(inputStream, "UTF-8")
val bufferedReader = BufferedReader(inputStreamReader)
var temp = bufferedReader.readLine()
while (temp != null) {
stringBuilder.append(temp)
temp = bufferedReader.readLine()
}
inputStream.close()
}

return stringBuilder.toString()
}

@SuppressLint("Range")
@RequiresApi(Build.VERSION_CODES.S)
override fun onNotificationPosted(sbn: StatusBarNotification) {
super.onNotificationPosted(sbn)

val projection = arrayOf("_id", "ct_t")
val query: Cursor? = contentResolver.query(MMS_URI, projection, null, null, null)
mmsRcvNumbers.clear()
if (query != null) {
if (query.moveToFirst()) {
do {
var mId = query.getString(0)
var fromAddr = getAddressNumber(mId.toInt())
if (!"".equals(fromAddr)) {
var mmsBean = MmsBean(mId.toInt(), fromAddr.toString())
var mesg = getMMsPart(mId)
mmsBean.mesg = mesg
// Log.e(TAG, "mesg = ${mesg}")
mmsRcvNumbers.add(mmsBean)
}
Log.e(TAG, "sms id=$mId [$fromAddr]")
} while (query.moveToNext())
}
}
if (mmsRcvNumbers.size > 0) {
mmsRcvNumbers.sortByDescending { orderItem -> orderItem.mId }
}

var sdf = SimpleDateFormat("yyyy-MM-dd kk:mm:ss", Locale("ko", "KR"))
val extras = sbn.notification.extras

try {

var title = extras.getString(Notification.EXTRA_TITLE).toString()
var content = "" + extras.getCharSequence(Notification.EXTRA_TEXT)
if (extras.getCharSequence(Notification.EXTRA_SUB_TEXT) != null) {
content += " " + extras.getCharSequence(Notification.EXTRA_SUB_TEXT)
}
var number = ""
if (mmsRcvNumbers.size > 0) {
number = mmsRcvNumbers.get(0).rcvNumber
content = mmsRcvNumbers.get(0).mesg
}

Log.e(
TAG, "onNotificationPosted ~ " +
"\n packageName: " + sbn.packageName +
"\n id: " + sbn.id +
"\n postTime: " + sdf.format(sbn.postTime) +
"\n title: " + title +
"\n number: " + number +
"\n content : " + content
)




} catch (e: Exception) {
Log.e(
TAG, "error = ${e.localizedMessage}"
)
}
}

}

코드에서 보는 것과 같이 Notification Receiver을 활용해 contentResolver 을 이용 content://mms-sms/conversations의 정보를 읽어옵니다. 이 정보를 활용해서 mms의 정보를 열어 보는 방식으로 구현이 되었습니다.


 


이 부분을 조금 더 활용해 본다면, sms 수신 데이터의 정보도 읽어 올 수 있도록 구현이 될 것 같습니다. 


나중에 기회가 된다면 그 부분도 구현을 해 보도록 하겠습니다.


 


 





오늘의 이야기

초기화면



이 글은 주문이요(bespeak) 앱 사용자를 위한 사용자 설명서입니다.

이 앱의 기획의도는 매장 테이블에 있는 간편 결제 키오스크를 보고 만들기 시작 헸습니다. 스마트폰 간편 결제만 사용한다고 하면 키오스크를 만들기 위해서 따로 뭔가를 준비하거나 할 필요가 없습니다.

이 앱은 payapp.kr을 간편 결제 서비스를 연동하고 있습니다.
이 앱은 qrcode 인증을 통해서 휴대폰 간편 결제를 할 수 있도록 지원하는 앱입니다.

이 앱을 사용하기 전에 꼭 payapp.kr 에서payapp.kr에서 회원 가입 및 사용자 인증을 진행한 이후에 사용할 수 있습니다. (그렇다고 해서 아직은 payapp 와의 어떤 관계도 없습니다. 혹시 나중에 리셀러로 참여할지도 모릅니다. 현재는 payapp.kr에서 3.4% 부가세 별도의 수수료를 지불하면 웬만한 카드사는 다 가맹점으로 사용할 수 있습니다. 참 온라인 판매는 보증보험이 필요하다고 합니다. )

이 앱은 스마트폰 간편 결제만 지원하고 있습니다. 다른 결제 방식에 대해서는 추가 upgrade는 검토 후 진행 여부가 결정될 수 있습니다.

사용하지 않는 스마트 단말만 있으면 됩니다. 단, wifi 는 연결될 수 있어야 합니다. 통신을 해야 합니다.





Ver.0.3.4 개선 사항


관리자 화면 수정 사항



Ver 0.3.4 에서 부터는 페이앱 사용자 가입 이라는 링크가 추가 됩니다. 


 


해당 링크를 통해서 페이앱에 가입 하고 이 앱을 사용하시는 분들께는 간편결제 수수료가 3% (부가세 별도)로 적용 됩니다. 


 


해당 링크를 통해 페이앱에 가입 하신 경우에는 nar961450@gmail.com 을 알려 주세요.


 


간편결제 수수료를 적용해 드립니다. 


 


 


 


 


 


 


 


 


 


 


 


 


 


 


 


 


앱 사용자 등록 하기


앱을 설치하고 나서 처음 시작할 때 나오는 화면 입니다.


최초 실행시 사용자 등록 화면



** 이 메일은 사용자 구분을 하기 위한 정보로만 사용됩니다. 나중에 관리를 위해서도 필요합니다. 등록된 이메일 주소를 활용해서 메일링을 하거나 할 생각은 없습니다.

** 비밀번호는 이앱의 사용자 등록을 위해서만 사용 됩니다. 최초 화면에서 비밀번호를 등록 하면 이후에는 수정이 불가 합니다. 이후 비밀번호를 분실한 경우에는 개발자에게 메일로 알려 주시면 변경해 드립니다.
(테스트를 위해 예시 화면과 같이 입력 하는 경우에는 변경하실 수 없습니다.)

** 페이앱 사용자 ID
payapp에 가입하게 되면 등록한 사용자 ID가 있어야 결제를 받은 것들에 대해서 대금 수령 등을 할 수 있습니다. 이 부분에 대해서는 payapp의 사용자 등록을 참고하세요.

** T.No
테이블 번호로 사용됩니다. 매장 안에 테이블이 여러 개 있는 경우 번호를 달리 해야 나중에 주문 내역 관리에 도움이 됩니다.

** 주방 데스크 여부
해당 단말을 주방에 설치하는 경우에만 체크해 주세요.
주문이 완료되면 해당 정보를 목록 형태로 조회할 수 있도록 지원됩니다.

현재 표시된 내용은 샘플이므로 그대로 저장을 해서 테스트해 보셔도 됩니다.
테스트시에 결제가 진행된 것은 15분 이후 정도에 자동 취소가 진행됩니다. (혹은 목록에서 취소 처리를 할 수 있습니다.)


요구되는 권한


이 앱에서 사용 되는 중요 권한은 아래와 같습니다. ** 위치 정보, 항상 허용까지 필요, ** 카메라 사용 권


요구되는 권한



** 위치 정보 : 위치 정보는 이앱에서 사용 되는 권한입니다. 이 권한은 이앱이 설치된 휴대장치가 지정된 위치(사업장 위치등등)을 이탈 하는 경우 해당 휴대장치의 위치정보를 관리자가 사용하는 앱에게 알림을 전달 하기 위해서만 사용 됩니다. 또한, 항상 허용을 요청하는 이유도 해당 휴대장치의 위치 정보를 지속적으로 확인하기 위해서만 사용 됩니다.

** 위치 정보( 항상 허용 포함) 중요한 개인정보에 해당 하기 때문에 이 앱에서 제시하고 있는 것 처럼 개인정보를 취급함에 있어 주의를 다하며 절대로 개인정보를 수집 하거나 부당하게 공유하지 않습니다.
https://billcorea.tistory.com/m/294



 


주문이요 앱 사용자를 위한 개인정보 처리 지침


은(는) 「개인정보 보호법」 제30조에 따라 정보주체의 개인정보를 보호하고 이와 관련한 고충을 신속하고 원활하게 처리할 수 있도록 하기 위하여 다음과 같이 개인정보 처리방침을 수립·공개


billcorea.tistory.com




** 카메라 사용 권한 요청 : 상품 등록 화면으로 들어갔을 때 관리자의 경우에는 이미지 찾기 또는 카메라 촬영을 위해서 카메라 사용 권한을 물어 보게 됩니다. 권한을 승인 하지 않는 경우 상품 등록을 진행할 수 없으므로, 반드시 카메라 사용 권한을 허가해 주어야 합니다.


관리자를 위한 화면


관리 화면



관리화면에서 주의할 부분은 다음과 같습니다.
* 이메일 . 비밀번호는 이앱에서 입력되는 자료를 조회할 사용자 계정이기 때문에 분실 되지 않도록 관리 되어야 합니다. 비밀번호를 분실한 경우는 현재까지는 개발자에게 조치를 요청해야 합니다. (이후 개선을 통해서 비밀번호 변경을 할 수 있도록 할 예정 입니다.)
* 페이앱 사용자 ID : payapp.kr 페이앱에서 사용자 등록한 계정을 사용 하여야 합니다. 변경하는 경우 거래가 되지 않을 수 있으므로 관리에 주의가 필요 합니다
* T.No : 테이블 구분하여 관리 하고자 하는 경우에 사용 됩니다. 사용자의 필요에 따라서 등록 관리할 수 있습니다.
* 장치 위치 설정을 진행하세요 를 클릭 하면 위 그림 중에 2번째 그림과 같은 화면에 나옵니다.
이 때에는 화면에서 사업장 위치를 길게 클릭 하면 해당 위치를 사업장 위치로 선택 되게 됩니다.
* 알림 버튼 : 알림 버튼을 클릭 하면 주기적(최소 15분 간격으로)적으로 앱이 설치된 휴대장치의 위치 정보를 확인하게 됩니다. (파란색이 되는 지 확인 하여야 합니다. 중지된 경우는 검정색으로 나옵니다.)
* 장치 위치 설정을 진행하세요 옆에 파란색 휴대장치 아이콘 클릭시 에는 위 그림에서 3번쨰 그림이 나옵니다. 이 화면에서는 휴대장치가 위치를 이탈한 경우 해당 위치를 표시하도록 하고 있습니다.


처음 화면


처음화면



사용자 계정 등록 후 처음 화면으로 넘어오면 이렇게 보입니다.

먼저 하단의 메뉴 구성은
** 주문
사용자가 주문을 하기 위해서 선택합니다.

** 상품
상품의 상세 조회 시 또는 관리자의 경우 상품을 등록할 때 사용합니다.

** 관리
앱을 시작한 이후 관리를 위해서 사용하거나, 주문 목록을 보고자 하는 경우에 볼 수 있습니다.

이제 상품 목록을 보기 위해서 refresh 버튼을 클릭해 봅니다.
사용자가 로그인되어 있다면 상품 목록이 보입니다.










로그인을 하기 위해서 비밀번호를 입력해야 합니다. 비밀번호는 최초 실행시 등록한 비밀번호를 기준으로 관리 되며, 추후 비밀번호 변경 기능등은 추가 지원될 예정입니다.

(테스트 계정 nana1029@billcorea.com 의 비밀번호는 1qazZAQ! 입니다)


상품 등록하기


앱에서 상품을 등록하기 위해서는 로그인 이후 상품 메뉴로 이동합니다.




상품등록을 하기 위한 화면은 위와 같이 4단계에 걸쳐 진행이 되어야 합니다.
* 먼저 화면 상단의 지문인식 버튼을 클릭합니다. (이때 pin 이 등록된 경우 pin으로도 인증이 가능합니다.) 스마트폰에 등록된 지문으로 인증이 완료되어야 다음으로 진행이 가능합니다.
** 다음은 상품 이미지 촬영을 위해서 카메라 권한을 획득하여야 합니다. 상품 이미지 안에 있는 카메라 권한 획득 버튼을 클릭하여 권한 획득을 하세요.
** 상품 이름부터 순차적으로 내용을 입력하고, 저장 버튼을 클릭하면 자료가 저장됩니다.
** 지문 인식이나 PIN 확인은 앱을 설치한 폰에 등록된 바이오 인증을 활용 합니다. (but 절대 그 비밀번호를 알아서 확인이 되는 것이 아니고 안드로이드에서 지원하는 기능으로 체크 되는 지만 활용하고 있습니다.)

이후 초기 화면에서 등록된 내용을 조회해 보고 수정이 필요한 경우 이미 왼쪽 상단의 수정 버튼을 클릭하여 다시 수정할 수 있습니다.


상품 판매하기


이제 등록된 상품을 팔아 보도록 하겠습니다. 등록된 상품의 카테고리는 일반 음식점 기준으로 되어 있습니다. 주메뉴, 사이드 메뉴, 음료/주류로 분류된 메뉴판을 볼 수 있습니다.


상품판매



상품 판매 화면의 모습입니다. 화면 상단에는 회색의 카드 모양 결제 버튼과 파란색의 다시 읽어오기 버튼이 존재합니다

** 다시 읽어오기 (파란색 버튼)을 클릭하면 화면에 메뉴들이 조회 됩니다. 메뉴는 등록하는 화면에서 보았던 것처럼 3가지 분류로 등록된 메뉴들을 조회해 볼 수 있습니다.

** 메뉴 화면의 쇼핑카드 모양을 클릭 하면 주문량이 늘고 가격이 자동 산출됩니다. 가격이 산출되면 회색 모양이 카드 버튼이 파란색으로 활성화됩니다.

** 메뉴 선택이 끝났으면 이제 파란색 카드 버튼을 클릭해서 결제를 시작해 보겠습니다.

** 직원의 도움이 필요한 경우 파란색 벨을 클릭 하면 직원의 화면으로 호출 안내가 전달 됩니다.

** 도움말 버튼을 클릭 하면 이 사용자 메뉴얼 페이지를 볼 수 있습니다.









결제 진행해 보기


결제는 QR 코드가 보이면 그걸 고객(결제할 사람)의 스마트폰에 있는 카메라 앱으로 보여 주면 삼성폰의 경우는 카메라 앱에서 QR코드를 인식해서 URL 링크 팝업이 나타납니다. 인식이 되지 않는 다면 QR Scaner 앱을 실행시켜서 해야 합니다.


결제 흐름



결제하기 전에 고객의 휴대전화 번호를 입력하도록 안내하고 있습니다. 고객의 실적 집계를 구현할 수 있는 기초 자료가 되기 하고, 일단 취소가 되어야 하는 경우 필요한 정보로 사용됩니다. 전화번호 입력이 되고 나면 다음은 QR코드가 있는 화면으로 넘어갑니다. 아래로 내려 보면 고객(결제할 사람)이 따라 해야 하는 부분을 이미지로 표시해 두었습니다.

결제가 되고 나면 앱에서는 목록 앞에 결제된 건에 대해서 파란색 카드 모양으로 활성화됩니다. 이때 취소를 하고 싶다면 파란색 카드 버튼을 클릭하면 취소를 진행할 수 도 있습니다.


취소 처리



결제 취소가 되는 경우는 이전 화면으로 돌아갑니다. 다시 선택을 하고 결제를 진행하여야 합니다.


주방 Order 모아 보기


주방화면



 


** 관리 화면에서는 등록된 주문 목록이 전체가 조회됩니다. (단, 등록된 날자가 오늘은 경우만 조회 됩니다. 24시를 넘어가는 경우 어제 날자 오더는 볼 수 없습니다.)

** 오더 조회 시간에 대한 의견을 주시면 수정을 해 보도록 하겠습니다.

** 목록이 나오지 않는 경우에는 파란색 메뉴판 버튼을 클릭해서 조회를 할 수 있습니다.

** 느낌표 안내 버튼은 앱의 기능에 대한 간략한 정보를 전달합니다.

** 물음표 안내 버튼은 사용설명서 페이지 (이 페이지)로 이동하도록 하고 있습니다.

** 로그아웃 버튼은 앱을 종료합니다. 다시 앱을 실행하면 로그인을 새로 해야 합니다.

** 파란색 지문 버튼은 관리자를 위한 추가 입력사항 (처음에 입력했던 정보)를 수정할 수 있는 화면이 보입니다.

** 지문 인식이나 PIN 확인은 앱을 설치한 폰에 등록된 바이오 인증을 활용 합니다. (but 절대 그 비밀번호를 알아서 확인이 되는 것이 아니고 안드로이드에서 지원하는 기능으로 체크 되는 지만 활용하고 있습니다.)


주방에 알림 처리


주방에 알림 팝업







테이블에서 오더가 발생하면 주방 데스크로 선정한 단말에는 알림이 발생됩니다.

** 이 화면에서도 결제된 건은 결제에 파란 카드 버튼이 나오고,

** 결제 대기 중이거나, 취소된 경우는 회색 카드 버튼이 나타납니다.

** 파란 카드 버튼일 때는 클릭하면 취소를 진행할 수 있습니다.

** 오더는 오늘 중으로 발생된 것만 조회가 되고 있습니다. 추후 영업시간 등으로 조회 시간을 개선해볼 예정입니다.

** 제목 줄을 클릭하면 순서 정렬를 해 볼 수 있습니다. 예를 들어 상품명을 클릭 하면 상품명(내부 코드 기준) 순으로 정렬을 할 수 있습니다. 한번더 클릭 하면 역순 정렬도 진행됩니다.
보시기 편하도록 변경해 보세요.







직원호출 기능 추가


앱이 업데이트(ver.0.1.5 ~) 가 되면서 추가 되는 기능 입니다.


호출 서비스



상품 구매 화면 상단의 호출 버튼을 클릭 하면 직원 호출 여부를 확인하고, 확인을 클릭 하면 주방 단말로 설정된 휴대 장치로 알림을 전달 합니다. 알림이 도착 하면 알림을 표시 하는 팝업이 나타나고 휴대폰의 뒤로 가기를 클릭해서 해당 화면에서 나가면 관리 페이지에서 해당 정보를 확인할 수 있습니다.

호출 내역의 버리기 버튼을 클릭 하면 호출 이력을 삭제 합니다.


판매 리스트 엑셀로 받기


이 기능은 관리 페이지의 3번째 보고서 버튼을 클릭 하면 실행 됩니다.


매출내역 이메일 통지 받기



최근 7일간의 매출 내역을 엑셀 등으로 받고 싶은 경우에 시용할 수 있습니다. 버튼을 클릭 하면 내부적으로 최근 7일 동안 발생한 오더 리스트를 엑셀에 목록으로 저장 하고, 저장된 파일을 공유 하는 처리가 진행 됩니다. 엑셀이 첨부 되어 진행이 되어야 하기 때문에 꼭 이메일로 발송 될 수 있도록 처리를 부탁 드립니다.

2022.12.03 현재까지 적용된 기능에 대한 설명을 마칩니다. 이 앱을 이용해 보고 싶다거나, 개선이 필요한 부분이 있다고 생각되시는 부분이 있으시면 6k2emg@gmail.com으로 메일을 보내 주시면 확인하는 데로 회신해 드리겠습니다. 다만, 즉시적인 응답은 안될 수 도 있습니다. 감사합니다.

수정된 버전에 출시 되었습니다.
https://play.google.com/store/apps/details?id=com.billcoreatech.bespeak1003&pli=1



 


주문이요, 미니키오스크 - Google Play 앱


매장안에 있는 테이블에서 주문을 실시간으로 받고 주방으로 전달해 앱 입니다. 나만의 키오스크를 마련해 보세요


play.google.com




 





오늘의 이야기


#billcorea #운동동아리관리앱
🏸 Schneedle, een onmisbare app voor badmintonclubs!
👉 Matchplay - Registreer scores en vind tegenstanders 🎉
Perfect voor overal, alleen, met vrienden of in een club! 🤝
Als je van badminton houdt, probeer het dan zeker

Ga naar appen 👉 https://play.google.com/store/apps/details?id=com.billcorea.matchplay




오늘의 이야기

Telephony


우리가 사용하는 스마트 폰의  기능 중에서 SMS (short message service)와 LMS (Long message Service)의 수신에 대한 이야기를 해 볼까 합니다. 


 


https://support.google.com/googleplay/android-developer/answer/9888170



 


민감한 정보에 액세스하는 권한 및 API - Play Console 고객센터


Health Connect by Android 권한 Health Connect 권한을 통해 액세스되는 데이터는 사용자 데이터 정책과 다음과 같은 추가 요건이 적용되는 개인 정보 및 민감한 사용자 데이터로 간주됩니다. 적절한 Health


support.google.com




구글은 사용자의 개인정보보호등의 사유 등을 들어 SMS 기본 처리 앱으로 허가되는 경우를 제외하고는 SMS의 수신에 대한 허가를 얻어야만 그 권한을 사용하는 앱의 playstore 등재를 허락하고 있습니다.  그런 사유로 해서 SMS을 수신하는 앱을 playstore에 게시하는 것은 개인 개발자가 하기에는 어려운 사항이 생기고 있습니다. 


 


SMS 읽기


그래도 playstore 에 등록하는 경우가 아닌 개인적으로 사용하고자 하는 경우에는 이 기능에 대한 제한을 할 수 없을 듯합니다.  그래서 이번에는 SMS 수신하는 앱을 하나 만들어 볼까 합니다. 


 


먼저 권한 획득을 해 보겠습니다.


<uses-permission android:name="android.permission.RECEIVE_SMS" />

 


PERMISSION 얻기


SMS 등 민간한 권한의 경우는 manifest에 선언을 하는 것뿐만 아니라 코드 내부에서도 권한을 다시 허가를 받아야 한 하도록 google에서 제한하고 있습니다. 이렇게 허가를 받는 다고 해도 playstore에 게시할 때도 다시 제한을 하고 있으니 (2022.11.21 현재에는...) playstore에 게시를 하고 싶다면 다시 생각을 해 보아야 합니다.


 


이제 코드를 살펴 보도록 하겠습니다.


@OptIn(ExperimentalPermissionsApi::class)
@Composable
private fun FeatureThatRequiresReceiveSmsPermission(
doResult:(ty:Boolean) -> Unit
) {

// Camera permission state
val receiveSmsPermissionState = rememberPermissionState(
Manifest.permission.RECEIVE_SMS
)

when (receiveSmsPermissionState.status) {
// If the camera permission is granted, then show screen with the feature enabled
PermissionStatus.Granted -> {
doResult(true)
}
is PermissionStatus.Denied -> {
Column(
modifier = Modifier.padding(3.dp),
horizontalAlignment = Alignment.End
) {
val textToShow = if ((receiveSmsPermissionState.status as PermissionStatus.Denied).shouldShowRationale) {
// If the user has denied the permission but the rationale can be shown,
// then gently explain why the app requires this permission
stringResource(id = R.string.msgGetPermissonSms)
} else {
// If it's the first time the user lands on this feature, or the user
// doesn't want to be asked again for this permission, explain that the
// permission is required
stringResource(id = R.string.msgGetPermissonSms)
}
IconButton(onClick = {

receiveSmsPermissionState.launchPermissionRequest()
doResult(false)

}) {
Icon(
imageVector = Icons.Outlined.Sms,
contentDescription = "Sms",
tint = softBlue
)
}
Text(textToShow)
}
}
}
}

코드는 google 에서 검색해서 찾은 코드를 일부 수정했습니다. java 코드로 구현할 때 보다 훨씬 수월하게 구현이 됩니다. 간단하게 구현이 되네요.


 


코드 인용


https://google.github.io/accompanist/permissions/



 


Guide - Accompanist


Jetpack Compose Permissions A library which provides Android runtime permissions support for Jetpack Compose. Warning The permission APIs are currently experimental and they could change at any time. All of the APIs are marked with the @ExperimentalPermiss


google.github.io




참고한 사이트의 내용을 보면 저 코드를 구현하기 위해서는 gradle 에 설정이 들어가야 합니다.  다음과 같습니다. 


 


// 권한 획득
implementation "com.google.accompanist:accompanist-permissions:0.27.1"

 


이걸 이제 호출해서 잘 동작 하는 지 봐야 할 것 같아서 구분의 구현에 대한 코드를 보여 드립니다.


setContent {
var isGrantCamera by remember {
mutableStateOf(false)
}
var isGrantPhone by remember {
mutableStateOf(false)
}
SmsReceiver1113Theme {
// A surface container using the 'background' color from the theme
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colors.background
) {
Column(modifier = Modifier
.fillMaxSize()
.padding(3.dp),
) {
FeatureThatRequiresReceiveSmsPermission(
doResult = {
isGrantCamera = it
}
)
...
ScreenView(
appVersion,

...
)
}
}
}
}

위 코드와 같이 mainactivity 에서 호출을 하는 것만으로 권한이 획득됩니다. 실행된 모습은 아래 그림과 같습니다. 


권한획득 화면



이제 저 버튼을 클릭 하면 저 문자 이미지 버튼이 사라집니다. 그것으로 권한 획득이 이루어집니다.  


 


SMS 수신기 만들기


import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.os.Bundle
import android.telephony.SmsMessage
import android.util.Log
import com.billcoreatech.smsreceiver1113.BuildConfig
import com.billcoreatech.smsreceiver1113.retrofit.RetrofitService
import com.billcoreatech.smsreceiver1113.retrofit.SmsInfoBean
import java.text.SimpleDateFormat
import java.util.*

class MySmsReceiver : BroadcastReceiver() {

private var TAG = "MySmsReceiver"
private lateinit var context: Context
var contentBp = mutableListOf<String>()

override fun onReceive(context: Context, intent: Intent) {

Log.e("", "onReceive ...")

this.context = context

if(intent?.action.equals("android.provider.Telephony.SMS_RECEIVED")){
val bundle = intent?.extras
val messages = smsMessageParse(bundle!!)
var content = ""

if(messages?.size!! > 0){

// LMS 수신을 위해서
contentBp.clear()
for(message in messages) {
Log.e(TAG, "message=${message?.messageBody}")
contentBp.add(message?.messageBody.toString())
}

content = contentBp.toString() // 수신 문자 내용 전체
var number = messages[0]?.originatingAddress.toString() // 전송한 전화번호
var currentTime = messages[0]?.timestampMillis // 메시지 수신시간

Log.e("TAG ... ","get ${number} ${content} ${certNumber}")

var sdf = SimpleDateFormat("yyyy-MM-dd kk:mm:ss", Locale("ko", "KR"))
// val currentTime = Date(System.currentTimeMillis())

Log.e("", "date time = ${sdf.format(currentTime)} ${content}")

var sp = context.getSharedPreferences(context.packageName, Context.MODE_PRIVATE)
if ("debug".equals(BuildConfig.BUILD_TYPE)) {
.....
} else {
.....
}
}
}
}

fun smsMessageParse(bundle: Bundle): Array<SmsMessage?>? {
val objs = bundle["pdus"] as Array<Any>?
val messages: Array<SmsMessage?> = arrayOfNulls<SmsMessage>(objs!!.size)
for (i in objs!!.indices) {
messages[i] = SmsMessage.createFromPdu(objs[i] as ByteArray)
}
return messages
}

}

이 코드는 문자가 수신되면 안드로이드가 알려 주는 broadcasting receiver 입니다.  여기서 기억을 하고 가야 할 부분은 다음 부분입니다.  문자가 수신되면 bundle.extra 에서 sms messages 가져옵니다. 그중에서 messageBody 부분은 array로 구성되어 긴 문자가 오게 되면 각각의 array 공간에 쌓이게 됩니다. 이걸 다 가지고 올 건가, 아니면 처음 (index 가 0 인)만 가지고 올 건가 에 따라서 수신하는 메시지가 전체인지 아닌지 알게 된다는 것입니다.


// LMS 수신을 위해서
contentBp.clear()
for(message in messages) {
Log.e(TAG, "message=${message?.messageBody}")
contentBp.add(message?.messageBody.toString())
}

이제 manifest 에 수신기를 등록해 보겠습니다.


<receiver
android:name=".MySmsReceiver"
android:enabled="true"
android:exported="true">
<intent-filter>
<action android:name="android.provider.Telephony.SMS_RECEIVED" />
</intent-filter>
</receiver>

이렇게 receiver 을 등록하는 것으로 코드 구현은 다 되었습니다.  이제 잘 사용해 보는 것만 남았습니다.


 





오늘의 이야기

창업 디자이너. 창디


앱을 구현하다 보면 아무래도 개발자로만 지내면서 감히(?) 도전해 보지 않았던 디자인과 관련된 이슈를 마주 하게 됩니다. 그래서 이번엔 어느 디자인 전문가님에게 앱의 기본 디자인에 대한 평가(?)를 받아 보기로 했습니다. 


 


창업디자이너 창디



https://www.instagram.com/changdi.designer/


별 그램에서 디자인에 관한 글을 많이 포스팅하시는 분이 무료 이벤트를 하신다고 하여 얼른 손(?)을 들어 보았답니다. 


창업 디자이너. 창디님 이라고 하시더라고요.


 


화면디자인 설계


 


초안과 검토전 디자인



처음 이미지는 처음 만들어 가던 화면 디자인이고,  다음 이미지는 창디님에게 조언을 듣기 위해 전달드렸던 이미지입니다.  개인적으로 파란색을 좋아해서  온통 스머프(?)가 생각나는 색상으로 도배(?)를 했더랍니다.  이제 창디님의 조언을 들어 보겠습니다. 


 


 



창디님의 혹독한(?) 디자인 평가 듣기


평가를 듣고 나서


1. 배경색은 하얀 배경으로 했던 건데, 노란색이 되면 어떨까 하는 생각에서 칠했던 것이고, 
2. 폰트도 귀엽기는 하죠... nano sans 등의 폰트를 찾아보겠습니다.
3. 파란색은 그냥 개인적으로 좋아하는 색이고요.
4. 각 item들끼리 묶어서 분명해지도록 해야겠고요.
5. 글자의 색에 대해서도 쉽게 black으로만 썼었는데... dark gray 공감이 확 옵니다.
   dark blue는 안될까요? 


 


이런 정도의 복습(?) 의견을 드리긴 했습니다. 그리고 이제 수정된 디자인에 도전을 해 보았습니다. 


 


수정된 디자인


수정한 디자인



 


배경색은 무채색을 강조하셨는 데, 변경한 배경색이 무채색 같지는 않습니다. 


 


폰트는 나눔 고딕 정도로 수정해 보았습니다. 


파란색은 개인적으로 많이 좋아해서 버리지 못했습니다. 


 


아이템들은 그룹화를 해서 구분이 수월하게 해 보았습니다. 


 


글자색도 dark gray로 변경해 보았습니다. 


 


확 와닿은 건 글자색을 확실한 black으로 한다는 말씀이었습니다. 일반적으로 디자인을 배워 보지 못한 이들은 white와 black으로 대비되는 색을 적용해 보게 됩니다. 


 


 


 


 


 


 


 


 


 


 


 


 


 


 


 


 


 


이렇게 수정된 화면 디자인은 어떤가요?


 





오늘의 이야기


#스하리1000명프로젝트,
한국에서 길을 잃었나요? 한국어를 못하더라도 이 앱을 사용하면 쉽게 돌아다닐 수 있습니다.
귀하의 언어로 말하면 귀하의 언어로 번역, 검색 및 결과가 표시됩니다.
여행자에게 좋습니다! 영어, 일본어, 중국어, 베트남어 등 10개 이상의 언어를 지원합니다.
지금 사용해 보세요!
https://play.google.com/store/apps/details?id=com.billcoreatech.opdgang1127




2026/03/11

오늘의 이야기

Cloud Function


서버를 보유 하지(Serverless) 않고 서버가 있는 것처럼 업무 구현을 하고 싶습니다. 개발을 하면서 데이터 베이스와 스토리지를  firebase을 활용하고 있다면 cloud function 도 배워서 준비를 해야 할 것 같습니다. 지금 개발하고 있는 앱이 payapp와 API 연동을 해야 하지만, 가난한(?) 개발자는 서버가 없습니다

대안으로 생각해 볼 수 있는 것을 찾아보다가 알게 된 cloud function의 구현에 대한 이야기를 해 보겠습니다.

* google cloud function : python, node.js, java 등등이 지원이 된다고 합니다.
** firebase cloud function : node.js 만 (2022.11.12 현재로는) 지원이 되고 있습니다

node.js을 잘 알고 있다면 firebase을 활용할 수 도 있겠지만, 아직은 python을 더 잘 알고 있기 때문에 google cloud function을 구현해 보기로 했습니다.

https://cloud.google.com/functions/docs/create-deploy-http-python?hl=ko#windows







빠른 시작: Python을 사용하여 HTTP Cloud 함수 만들기 및 배포  |  Cloud Functions 문서  |  Google Cloud


의견 보내기 컬렉션을 사용해 정리하기 내 환경설정을 기준으로 콘텐츠를 저장하고 분류하세요. 빠른 시작: Python을 사용하여 HTTP Cloud 함수 만들기 및 배포 Python을 사용하여 HTTP Cloud 함수 만들기


cloud.google.com




이 글은 위 링크의 내용에서 발췌하고 따라하기를 했음을 밝혀 둡니다.


Firebase realtime database 에 저장

저장된 결과


이 그림이 나의 목표 입니다. 서버가 없기 때문에 payapp의 API을 호출했을 때, 상대방 서버에서 feedback url로 보내주는 결과를 수신해서 database에 저장하는 것이 목표입니다.

이걸 준비 하는 과정은 가이드에 따라서 잘 따라가기를 하면 되기는 했습니다. 단지, 이해가 어려웠던 부분이 있다면
1. cloud client 설치
2. project 설정 ( 혹시 console.google 에 project 가 여러 개 있을 때 project을 선택해야 하는 문제)
3. 해당 project의 결제 계정 연결
4. function 연동 설정

이런 정도라고 볼 수 있습니다. 한번 해 보고 나서 정리를 하는 것이라, 쉽게 말하고 있기는 하지만, 이것 설정을 준비하는 동안만 3~4일 걸렸고, 어떻게 할까에 대한 고민을 여러 가지로 했습니다. 아무튼 이제 할 수 있습니다. 따라 하는 설명은
다음에 하도록 하겠습니다.





오늘의 이야기

https://github.com/ahmedmolawale/AndroidNanoHttpd



 


GitHub - ahmedmolawale/AndroidNanoHttpd: A sample android project to showcase the use of NanoHttpd in Android.


A sample android project to showcase the use of NanoHttpd in Android. - GitHub - ahmedmolawale/AndroidNanoHttpd: A sample android project to showcase the use of NanoHttpd in Android.


github.com




오늘은 번외 편으로 안드로이드를 이용한 웹서버 간략 구현에 대해서 알아볼까 합니다.  이 포스팅은 위 링크의 글을 참조하였음을 밝혀 둡니다. 


 


Gradle 추가


// nano HTTP 구현
implementation 'com.nanohttpd:nanohttpd:2.2.0'

이거 하나 추가 하면 끝입니다.  다음은 Local에서 WebServer로 사용할 코드를 구현해 보겠습니다. 



import android.content.Context
import android.os.StatFs
import android.util.Log
import androidx.core.content.ContextCompat
import fi.iki.elonen.NanoHTTPD
import java.io.File
import java.io.FileInputStream
import java.io.IOException
import java.net.Inet4Address
import java.net.NetworkInterface
import java.net.SocketException

class LocalWebserver(context: Context, port: Int) : NanoHTTPD(port) {

var context: Context? = context
val MIME_JAVASCRIPT = "text/javascript"
val MIME_CSS = "text/css"
val MIME_JPEG = "image/jpeg"
val MIME_PNG = "image/png"
val MIME_SVG = "image/svg+xml"
val MIME_JSON = "application/json"
val MIME_GIF = "image/gif"
val MIME_BMP = "image/bmp"
var mimeType = MIME_HTML
val folderName = "portfolio" //name of the folder holding the asset of the page you wanna load

val TAG = "LocalWebserver"

override fun serve(session: IHTTPSession?): Response? {
val uri = session?.uri
try {
when {
uri!!.endsWith(".js") -> {
mimeType = MIME_JAVASCRIPT
}
uri.endsWith(".css") -> {
mimeType = MIME_CSS
}
uri.endsWith(".html") -> {
mimeType = MIME_HTML
}
uri.endsWith(".jpeg") -> {
mimeType = MIME_JPEG
}
uri.endsWith(".png") -> {
mimeType = MIME_PNG
}
uri.endsWith(".jpg") -> {
mimeType = MIME_JPEG
}
uri.endsWith(".svg") -> {
mimeType = MIME_SVG
}
uri.endsWith(".bmp") -> {
mimeType = MIME_BMP
}
uri.endsWith(".gif") -> {
mimeType = MIME_GIF
}
uri.endsWith(".json") -> {
mimeType = MIME_JSON
}
}
} catch (e: Exception) {
Log.e(TAG, "MINE ERROR ... ${e.message}")
}
Log.e(TAG, "${getLocalIpAddress()} ${getStoragePath()}")
val root = "${getStoragePath()}${File.separator}"
var fis: FileInputStream? = null
val file = File(
root +
"${folderName}/${uri}"
)
try {
if (file.exists()) {
fis = FileInputStream(file);
}
} catch (ioe: IOException) {
Log.e("Httpd %s", ioe.toString())
}
return newFixedLengthResponse(
Response.Status.OK,
mimeType,
fis,
file.length()
)
}

private fun getStoragePath(): String? {
var path: String? = null
var space: Long = 0
val files: Array<File> = ContextCompat.getExternalFilesDirs(context!!, null)
// go through the options to choose one with more available storage capacity
for (f in files) {
val stat = StatFs(f.path)
val blockSize = stat.blockSizeLong
val totalBlocks = stat.blockCountLong

// check if storage capacity is more than the previous one
if (totalBlocks * blockSize > space) {
space = totalBlocks * blockSize
path = f.path
}
}
return path
}

private fun getLocalIpAddress(): String {
try {
val en = NetworkInterface.getNetworkInterfaces()
while (en.hasMoreElements()) {
val intf = en.nextElement()
val enumIpAddr = intf.inetAddresses
while (enumIpAddr.hasMoreElements()) {
val inetAddress = enumIpAddr.nextElement()
if (!inetAddress.isLoopbackAddress && inetAddress is Inet4Address) {
return inetAddress.getHostAddress().toString()
}
}
}
} catch (ex: SocketException) {
ex.printStackTrace()
}
return ""
}
}

코드는 참조한 링크의 내용을 그대로 구현 했습니다. 잘 모르는 부분도 있고 해서요. 코드를 보면 local IP을 확인하고, 해당 아이피를 기준으로 웹서버를 구동하는 그런 정도입니다.  추가적으로 해야 할 부분은 이제 화면에서 보여줄 html 코드를 구현해 보는 것인데, 


 


아직은 그럴 생각이 없기 때문에 그냥 이렇게 구현 하는 정도까지만 포스팅을 해 두겠습니다. 이제 실행을 해 볼까요?


 


웹서버 구동


var localWebserver = LocalWebserver(this@MainActivity, 8035)
localWebserver.start()

 이런 정도의 코드 구현으로 구현이 됩니다. 그러면 위 구현된 코드에서 getStoreagePath()에서 구해온 경로에 index.html을 등을 넣어 주면 호출이 되는 것을 알 수 있었습니다. 


 


구동된 로그 샘플



크롬 등으로 현재 테스트 중인 앱의 서버에 접속을 해 보고 그 실행 로그가 출력되는 것을 확인하였습니다.  지금은 내가 사용하는 스마트폰에 wifi debugging을 실행하고 앱을 빌드해서 실행해 본 것입니다.


 


이것으로 로컬 웹서버가 필요한 경우 구현해 볼 수 있을 것 같습니다.





오늘의 이야기



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

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

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

그것도 구글 Gemini로다가!

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

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

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


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




오늘의 이야기

앱을 만들어 수익을 얻는 방법 중 하나인 Admob 광고가 버전에 따른 중단 일정이 공지되고 있습니다.  잘 기억해 두었다가


playstore에 게시된 앱의 버전 패치에 참고하시길 바랄게요.


 


중단 일정 공지 내용


광고중단 일정



이 글은 아래 링크의 내용을 참조하였습니다. 


 


https://developers.google.com/admob/android/deprecation?source=ui&hl=ko 



 


지원 중단 및 일몰  |  Android  |  Google Developers


이 페이지는 Cloud Translation API를 통해 번역되었습니다. Switch to English 지원 중단 및 일몰 컬렉션을 사용해 정리하기 내 환경설정을 기준으로 콘텐츠를 저장하고 분류하세요. 새로운 주요 Google 모


developers.google.com




 





오늘의 이야기


#스하리1000명프로젝트

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

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

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

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





오늘의 이야기

이전 포스팅에서 SMS 또는 LMS을 읽어오는 부분에 대해서는 기술한 바 있습니다. 이여서 하는 이야기는 MMS을 확인해 보는 방법입니다. 이전 포스팅으로 MMS가 읽어져 오는 것으로 이해를 하고 있었습니다만... 실제로는 그것이 아니라는 특히 국내에...